All findings · Lucene finding · LFV-1055 · hnsw-rebalance-maxconn1-nontermination

With maxConn = 1, a merge that reuses an HNSW graph with a deleted vector never finishes

With an HNSW format set to maxConn = 1 (allowed: 1 to 512), a merge that reuses a segment's graph after one delete never ends: rebalanceGraph stops only when size * (1/M)^level reaches 0, which never happens for M = 1. forceMerge and close() hang until OutOfMemoryError closes the IndexWriter.

Summary

Priority
Low, rank 685The 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
hang
Since
10.4.0 (#15003, merged 2025-12-01); with the default codec since 10.5.1introduced 2025-12-01
Affected
10.5.1, 10.5.2, 10.6.0 and main with the default codec; 10.4.0 and 10.5.0 only with a non-per-field HNSW codec
Status
documented
Found by
a bounded-exhaustive harness of merge-time graph reuse with a termination oracle; independently, an SMT (Z3) obligation

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background in three steps

  1. HNSW levels: level 0 holds every vector, and each higher level a random, shrinking subset. With maxConn = M the builder puts a vector on level 1 or higher with probability 1/M; for M = 1 it uses e^-1, about 0.37.
  2. 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.
  3. Rebalance: for level 1, 2, 3 and so on it wants size * (1/M)^level vectors on the level, promotes vectors from the level below to reach that, and stops at the first level whose target is 0.
Summary card: with maxConn = 1, a merge that reuses an HNSW graph after a delete never finishes. What you get: new Lucene99HnswVectorsFormat(1, 100, 0), vectors {0}, {1}, {2}, delete id 0, forceMerge(1); expected: the merge returns, as with maxConn 2 (13 abort checks) or 16 (10); actual: still merging after 1,000 abort checks, never returns on its own, and the heap grows until OutOfMemoryError closes the writer. Why: invMaxConn = 1.0, so the target (int) (2 x 1.0^level) is 2 on every level and nextDouble() < 1.0 always holds; for M >= 2 the target reaches 0. Fix sketch: invMaxConn = M == 1 ? Math.exp(-1) : 1.0 / M; with it the merge finishes after 12 abort checks.
The 3-vector repro step by step: the reused graph after the delete, the rebalance target that stays 2 on every level for 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.

The finding, step by step

  1. 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 call forceMerge(1).
  2. You expect the merge to finish, as it does after 13 merge abort checks with maxConn = 2, 10 with maxConn = 16, and 17 with maxConn = 1 when nothing was deleted.
  3. Instead the merge reuses the old graph, drops the deleted vector and rebalances the levels: it wants size * (1/M)^level vectors 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.
  4. The test's guard stops it after 1,000 abort checks; 989 of the 1,001 were rebalance promotions, about 495 new levels. Unguarded, forceMerge and close() block while the heap fills: with 1,000 vectors and a 128 MB heap, OutOfMemoryError closes the writer and forceMerge fails after about 13 s.
  5. On 10.6.0 and main rollback() can still stop the merge; on 10.5.1 and 10.5.2 nothing can. A plain commit() after the delete starts the same endless merge in the background.

Who is affected

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.

The loop

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);                          // :477

For 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 numbers

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.

Why only M = 1

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.

What the caller sees

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, HnswBitVectorsFormat and the two sandbox formats DedupHnswVectorsFormat and DedupHnswScalarQuantizedVectorsFormat accept 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 = 1 is 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 plain commit() after the delete returns after about 0.55 s (its 500 ms merge-on-commit wait runs out) and leaves a merge thread running inside rebalanceGraph 3 s later; with maxConn = 2 the 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, forceMerge and close() that do not return normally, and a heap that grows until OutOfMemoryError closes 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.

Minimal repro

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.

TestHnswMaxConnOneMerge.java (lucene/core, package org.apache.lucene.codecs.lucene99, 1 test)71 lines
/*
 * 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);
    }
  }
}
Command
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 with maxConn = 2 (13 abort checks), and the test passes.
Observed
AssertionError: forceMerge(1) still running after 1,000 abort checks; 989 of the checks are rebalanceGraph promotions.

All files of this repro: repro/LFV-1055/

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0 release jars; hangs on 10.5.2
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • Every HNSW format accepts 1 to 512, and the graph builder handles M = 1 on purpose.
  • rebalanceGraph promises "the expected exponential decay in level sizes"; for M = 1 there is none.
  • A maintainer called maxConn = 1 degenerate for recall and connectivity; that explains poor results, not a merge that never ends.
The full argument202 words
  • Accepted and tested as valid. Lucene99HnswVectorsFormat rejects only maxConn <= 0 or > 512 (Lucene99HnswVectorsFormat.java:271-276; the same check in the other four writable HNSW formats, two of them in the sandbox), and its testLimits asserts that -1 and 0 are rejected, not 1 (TestLucene99HnswVectorsFormat.java:64-70). No javadoc, CHANGES.txt or MIGRATE.md entry restricts maxConn to 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 build M = 1 graphs (HnswGraphTestCase.testDiversityFallback and testDiversity3d, 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 maxConn 8 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.

Suggested fix

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 / M there.
  • 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 = 1 graph 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=true and false; git apply succeeds; with the fix :lucene:core:checkGoogleJavaFormat and 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 (TestHnswFloatVectorGraph 32, TestLucene99HnswVectorsFormat 60, TestKnnGraph 6, TestHnswMergeAbort 4).
  • 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.

Bound the loop by the source graph's level count

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.

Reject maxConn < 2 in the HNSW formats

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.

Downstream note

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.

How it was found

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 with IllegalArgumentException: initialCapacity must be > 0 ... got: 0 (the sibling LFV-1056) and never reached the rebalance. It was confirmed end to end through IndexWriter.forceMerge on 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, k-NN, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web2026-10-30199 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 PRs2026-10-30body, comments and reviews of 102 upstream items; the diffs of all 13 open PRs touching the HNSW merge filesnone changes or discusses the loop bound for M = 1
GitHub recheck2026-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 2f424fd428danothing 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, #13566other aspects of the same code (seed, repair and merge speed, degree decay, abort polling, long finite merges); none is this bug

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, 10.6.0 affectednever finishes; rollback() can abort it; the minimal test fails (-ea, -da)
10.5.1, 10.5.2 affecteddefault 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 onlydefault 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 affectedno rebalanceGraph; the merge finishes
fix A on main not affectedthe minimal test and 19 suites (352 tests) pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate1,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 realOwn 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 weakOwn 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), hangRe-ran the finder test (-ea, -da) and a maxConn = 2 control.
credibility audit and no-noise gate keep; claimRe-ran the test on 645d4fe14a20; reduced it to the 3-vector test, which fails and passes with the fix.
this doc reproduced, fix validatedMinimal 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 fixedRe-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 moderateRead 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 runsChecked 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.

Diagram file

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.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,548 words

Finding record

LFV-1055, snapshot of 2026-11-06

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, with maxConn 2 and 16 and no-delete controls; a commit()-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 main 2f424fd428da (2026-10-30) has no change to InitializedHnswGraphBuilder.java since d8468fd93757 (#16627, 2026-10-08).

Lucene and upstream sources

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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.