All findings · Lucene finding · LFV-159 · dense-nodes-iterator-consume-does-not-advance

HnswGraph.DenseNodesIterator.consume() does not advance: reading HNSW level 0 in chunks returns the first chunk forever

On level 0 of Lucene's in-memory and stored HNSW graphs, NodesIterator.consume(buf) copies the next node ids but never moves the iterator, so chunked reads return the first chunk forever and a while (hasNext()) consume(buf) loop never ends. Lucene never reads level 0 this way; outside code that does is hit, since 9.5.0.

Summary

Priority
Low, rank 1203DenseNodesIterator.consume() never advances on HNSW level 0, which no Lucene code reads that way.
Impact
minor
Since
9.5.0 (#11860, commit d0be9ab57c9, 2022)introduced 2022-12-07 · about 3 years
Affected
9.5.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading while modelling the HNSW node iterators

AI-assisted page: this finding was first signalled by code reading or testing 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 hands out one node iterator per level. The level-0 iterator's bulk read, consume(), copies the next ids but forgets to move forward.

Background: HNSW levels and node iterators

  1. Lucene's kNN vector index is an HNSW graph per segment and field. Level 0 holds every vector as a node, numbered 0 to size-1; each higher level holds a smaller subset.
  2. HnswGraph#getNodesOnLevel(level) returns a NodesIterator, an ordinary PrimitiveIterator.OfInt over that level's node ids: hasNext() / nextInt() one id at a time, or consume(int[] dest) to take the next batch into an array; it returns how many ids it wrote.
  3. There are three implementations. In Lucene's in-memory and stored graphs, level 0 gets DenseNodesIterator, which simply counts 0, 1, 2, ...; upper levels get ArrayNodesIterator or CollectionNodesIterator. Code outside Lucene gets a stored graph through HnswGraphProvider#getGraph, or an in-memory one from HnswGraphBuilder.
Diagram of the bug. Level 0 of an HNSW graph holds nodes 0, 1, 2 and 3, enumerated by a DenseNodesIterator with a cursor at 0. consume(int[2]) copies nodes 0 and 1 into the buffer and returns 2, but the cursor stays at 0, so the next consume copies 0 and 1 again, hasNext() stays true and a while (hasNext()) consume(buf) loop never ends; nextInt() afterwards returns 0. ArrayNodesIterator, used for upper levels, moves its cursor by the count (cur += numToCopy). With that one line added to DenseNodesIterator.consume, the second call copies 2 and 3 and hasNext() becomes false.
Level 0 with 4 nodes, read through an int[2] buffer: every consume() copies [0, 1] because the cursor stays at 0, so hasNext() stays true. With the one-line fix the cursor moves to 2 and the second call copies [2, 3]. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 2,000 vectors with the default codec and open the stored HNSW graph (HnswGraphProvider#getGraph). Level 0 holds every vector: getNodesOnLevel(0) returns an iterator over node ids 0 to 1,999.
  2. Read it in chunks of 7: while (it.hasNext()) { int n = it.consume(buf); ... }. You expect 0-6, then 7-13, and so on up to 1,995-1,999: 286 calls, every id once, then hasNext() is false. Level 1 of the same graph behaves exactly like that.
  3. Instead every call returns 0-6 again and hasNext() stays true: the loop never ends (the test stops it after 286 calls and 2,002 ids). A nextInt() after consume() also starts again at 0. Smallest case: new DenseNodesIterator(4) read twice into an int[2] gives [0, 1] both times.
  4. Why: Lucene's graphs use DenseNodesIterator for level 0, whose consume writes cur, cur+1, ... into the buffer but never adds the count to cur. The two other iterator classes, used for the upper levels, do.
  5. Lucene's only consume() call reads upper levels in one full-size call, so Lucene's indexes and search results are not affected. One line, cur += numToCopy;, fixes it.

Who is affected

Only code outside Lucene that reads level 0 with consume() more than once, or mixes it with nextInt() or hasNext(): a custom vectors writer, codec plugin or graph tool. Its loop then hangs or ids repeat. Lucene never does this, so its indexes and searches are fine; the downstream callers that were read make one full-size call on upper levels.

Mechanism, terms and numbers581 words

HnswGraph#getNodesOnLevel(level) returns a NodesIterator, a PrimitiveIterator.OfInt over the node ids of one graph level with a bulk method consume(int[] dest) documented as "Consume integers from the iterator and place them into the dest array", returning the number written. For level 0, which holds every node, every Lucene graph that enumerates level 0 returns a DenseNodesIterator over [0, size): the in-memory OnHeapHnswGraph, the default codec's stored graph (through HnswGraphProvider#getGraph), the lucene91-lucene95 back-compat readers and getSortedNodes(0) (the lucene90 back-compat graphs throw UnsupportedOperationException instead). Its consume (HnswGraph.java:241-251) writes cur, cur+1, ... and returns the count but never runs cur += numToCopy, while nextInt() and hasNext() use the same cur. So a second consume returns the same block, hasNext() stays true, a while (hasNext()) consume(buf) loop never ends, and nextInt() after consume() starts again at 0. The two sibling classes, used for upper levels, advance. Lucene's only production consume() call (Lucene99HnswVectorsWriter.writeMeta) reads upper levels in one full-size call, so the bug is latent: only code outside Lucene that reads level 0 with consume() is hit. It came with consume() in #11860 (9.5.0, 2022) and is in every release up to 10.6.0 and on main. The fix is one line.

Terms

  • NodesIterator (HnswGraph.java:161-186): "Iterator over the graph nodes on a certain level"; it implements PrimitiveIterator.OfInt (:166) and declares consume(int[] dest): "Consume integers from the iterator and place them into the dest array. @return The number of integers written to dest" (:179-185).
  • DenseNodesIterator (:230-265): "NodesIterator that enumerates [0, size)", with one cursor cur shared by consume, nextInt (return cur++;) and hasNext (cur < size).
  • Who returns it: getNodesOnLevel(0) of OnHeapHnswGraph (OnHeapHnswGraph.java:272-273), of the default codec's stored graph (Lucene99HnswVectorsReader.java:663-664) and of the back-compat readers (the lucene91, lucene92, lucene94 and lucene95 formats), and HnswGraph#getSortedNodes(0) (HnswGraph.java:149-150). Upper levels get ArrayNodesIterator or CollectionNodesIterator. getNodesOnLevel itself is abstract (HnswGraph.java:101): the lucene90 back-compat graphs throw UnsupportedOperationException, and a custom HnswGraph picks its own iterator.

The bug

  1. DenseNodesIterator.consume(dest) (HnswGraph.java:241-251) computes numToCopy = Math.min(size - cur, dest.length), writes dest[i] = cur + i and returns numToCopy. It never runs cur += numToCopy.
  2. So cur is where it was: the next consume copies the same block, hasNext() stays true, and nextInt() returns an id consume already handed out.
  3. The siblings move their cursor: ArrayNodesIterator.consume has the same body plus cur += numToCopy; (:212), and CollectionNodesIterator.consume fills dest through nextInt() (:277-289). Upper levels are therefore correct.
  4. Lucene never notices: its only production consume() call, Lucene99HnswVectorsWriter.writeMeta (:629-637), runs only if (level > 0), makes one call with a buffer of size() and drops the iterator. Every in-tree reader of level 0 uses hasNext() / nextInt().

The numbers

  • new DenseNodesIterator(4) consumed twice into an int[2]: [0, 1] and [0, 1] (expected [0, 1], [2, 3]).
  • Stored graph of a 2,000-vector default-codec index, level 0 read with consume(int[7]) in a while (hasNext()) loop: every call returns 0-6; the test's bound stops it after 286 calls and 2,002 ids (expected 2,000 distinct ids, then hasNext() false). Level 1 of the same graph returns every node once.
  • 1,000-document index, buffer 64: hasNext() is still true after 16 calls that returned 1,024 ids.
  • consume(new int[3]) returns 0, 1, 2; the next nextInt() returns 0 instead of 3.
Exposure and likelihood378 words
  • Who is hit. Code outside Lucene that reads level 0 of an HNSW graph with consume() in more than one call, or mixes consume() with nextInt() / hasNext(). The iterator is public API in the exported package org.apache.lucene.util.hnsw (module-info.java:50): the stored graph through HnswGraphProvider#getGraph (@lucene.experimental, in the exported package org.apache.lucene.codecs.hnsw, :76), a completed OnHeapHnswGraph from HnswGraphBuilder (which a custom KnnVectorsWriter holds during flush and merge; while it is still being built, getNodesOnLevel throws IllegalStateException), getSortedNodes(0) on any graph, or new HnswGraph.DenseNodesIterator(n).
  • What happens. A while (hasNext()) consume(buf) loop never ends on any non-empty level with any non-empty buffer; if it collects the ids, memory grows until the heap runs out. A loop that stops when the counts add up to size() ends, but when the buffer is smaller than the level it returns the first block repeatedly and misses the rest (by reading the code; with a buffer of at least size() such a loop is correct). consume() then nextInt() returns duplicates. One consume() into a size() buffer returns the right ids but leaves hasNext() true. A custom vectors writer that wrote level 0 from such a loop would hang on flush or write wrong graph data (by reading the code); Lucene's own writer does not do this.
  • Lucene is not affected. Its only production consume() call reads upper levels in one full-size call; every in-tree reader of level 0 uses nextInt(); the query-time searcher does not use these iterators. Stored graphs are correct: a public-API test reads level 1 of a stored graph in chunks and gets every node once. This covers Lucene's own code; a custom HnswGraph implementation chooses its own level-0 iterator.
  • The downstream code that was read is safe (a handful of repositories read during the prior-report search, not a survey). An Elasticsearch GPU HNSW writer, a cuVS helper and lucene-knn-oxide call consume only on upper levels with one full-size call; OpenSearch k-NN and cuVS have their own iterators that advance (read, not executed).
  • How likely. Rare: no report since 9.5.0 (2023), and the callers found use the one-shot pattern. A caller that drains level 0 with while (hasNext()) consume(buf) hits it on any non-empty graph; how many such callers exist is not known.

Minimal repro

One JUnit test, one method: new DenseNodesIterator(4), two consume(int[2]) calls, then check that the second chunk is [2, 3]. Public API only, no randomness, single thread; it fails with assertions on and off on main and on 10.6.0, and passes with the fix.

TestDenseNodesIteratorConsume.java (JUnit, one test)32 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.util.hnsw;

import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDenseNodesIteratorConsume extends LuceneTestCase {

  public void testSecondConsumeReturnsNextChunk() {
    HnswGraph.NodesIterator it = new HnswGraph.DenseNodesIterator(4);
    int[] chunk = new int[2];
    it.consume(chunk);
    it.consume(chunk);
    // expected the second chunk to hold the next node ids [2, 3]; actual [0, 1] again
    assertEquals(List.of(2, 3), List.of(chunk[0], chunk[1]));
  }
}
Command
cp TestDenseNodesIteratorConsume.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/hnsw/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.hnsw.TestDenseNodesIteratorConsume -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
Expected
The second consume fills [2, 3], the next two node ids, and then hasNext() is false.
Observed
It fills [0, 1] again: AssertionError: expected:<[2, 3]> but was:<[0, 1]>, with assertions on and off.

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

More files, environment, full output262 words

The test sits in org.apache.lucene.util.hnsw next to the upstream HNSW tests; HnswGraph.DenseNodesIterator and its constructor are public, so it needs no package-private access. It is the class getNodesOnLevel(0) returns for Lucene's in-memory and stored graphs, so the realistic path (a stored graph through HnswGraphProvider#getGraph, read with a while (hasNext()) consume(buf) loop) fails for exactly the same reason; that longer test was run by the verifiers and is described in the steps above.

As the regression test: the same test unchanged, as a new class next to the other HNSW tests in lucene/core/src/test/org/apache/lucene/util/hnsw/. It fails on stock main and passes with fix A, with assertions on and off (gradle).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0; every release from 9.5.0 to 10.6.0 is affected; 9.4.x has no consume()
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestDenseNodesIteratorConsume.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/hnsw/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.hnsw.TestDenseNodesIteratorConsume -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with =false, and on 10.6.0)
TestDenseNodesIteratorConsume > testSecondConsumeReturnsNextChunk FAILED
    java.lang.AssertionError: expected:<[2, 3]> but was:<[0, 1]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:69937511A3D822AF]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.util.hnsw.TestDenseNodesIteratorConsume.testSecondConsumeReturnsNextChunk(TestDenseNodesIteratorConsume.java:30)

1 test completed, 1 failed

# with fix A (cur += numToCopy): 1 test completed, 0 failed (assertions on and off)

# realistic path, verifier's public-API test (stored graph of 2,000 vectors, level 0 read with consume(int[7]))
java.lang.AssertionError: level 0 via consume: first 14 returned [0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6] expected:<2000> but was:<2002>

Why it looks unintended

  • NodesIterator is a PrimitiveIterator.OfInt; consume is documented to "consume integers from the iterator" and return how many it wrote. Nothing limits it to one call.
  • Both sibling classes advance; in #11860 only the dense branch of the original method skipped cur += numToCopy.
  • No test, comment or CHANGES entry describes a non-advancing consume.
The full argument307 words
  • Contract. NodesIterator implements PrimitiveIterator.OfInt (HnswGraph.java:166); consume: "Consume integers from the iterator and place them into the dest array. @return The number of integers written to dest" (:179-185). Consuming from an iterator removes what was consumed; a method that leaves the cursor in place would be a peek or a copy, and nothing documents it that way.
  • The code expects chunks. The array and dense versions compute Math.min(size - cur, dest.length) and copy from cur, not from 0; the collection version fills until dest is full; all return the count written. A partial fill points to callers that call again.
  • History. #11860 (d0be9ab57c9, 2022-12-07) added consume as one method with two branches: the array branch ran cur += numToCopy, the dense branch (nodes == null) returned before it. The review on HnswGraph.java went in three steps: jpountz objected that "It's awkward to have a method that returns the entire set of data on an iterator" (2022-12-02); benwtrent first asked whether a one-shot copy from index 0 would do ("Would you prefer: public void consume(int[] dest) { System.arraycopy(nodes, 0, dest, 0, dest.length); } Or something?", the same day); then wrote "I added a "consume" method to this iterator. Seems to fit nicer to the iterator model." (2022-12-05). The merged method copies from cur, not from 0, returns how many it wrote and advances in its array branch. #12050 and #15425 moved the dense branch unchanged; git log -G'cur \+= numToCopy' shows no commit ever added the advance there.
  • Tests and docs. #11860 added no test that calls consume; no upstream test calls consume on a level-0 iterator today; no comment, CHANGES.txt or MIGRATE.md entry describes a non-advancing consume.
  • Independent implementations advance. cuVS, OpenSearch k-NN and a Go port implement consume with an advance (read).
The devil's-advocate check378 words

The case for intended (the strongest version, from the intended-behaviour verifier and the cross-model reviews):

  • (a) "Internal": the #15425 CHANGES entry (lucene/CHANGES.txt:1399, 10.4.0) says "Refactoring internal HnswGraph.NodesIterator", and the org.apache.lucene.util package-info says "APIs in this package are primarily internal and may change between releases" (util/package-info.java:35).
  • (b) The consume javadoc never literally says "advances".
  • (c) Every known caller does one full-size consume() on levels above 0, where the result is right, so consume is a one-shot bulk copy in practice.
  • (d) In the #11860 review, benwtrent's first proposal was exactly such a one-shot copy from index 0, with no cursor: public void consume(int[] dest) { System.arraycopy(nodes, 0, dest, 0, dest.length); } (2022-12-02).
  • (e) The stored graph is handed out through HnswGraphProvider#getGraph, and HnswGraphProvider is @lucene.experimental; stock Lucene is not affected at all.

Why it fails:

  • (a) "Internal" in CHANGES is prose, not an API tag. The package-info sentence covers org.apache.lucene.util, a different Java package; the org.apache.lucene.util.hnsw package-info says nothing about internal use. HnswGraph, NodesIterator, DenseNodesIterator and its constructor are public, have no @lucene.internal or @lucene.experimental tag, and sit in an exported package.
  • (b, c) The count, the copy from cur and the two advancing siblings strongly indicate cursor semantics. The one-shot callers explain why the bug stayed latent, not what was intended: the array branch written in the same commit advances.
  • (d) That was a question in review ("Would you prefer: ... Or something?"). What was merged copies from cur, returns how many it wrote and advances in its array branch, and its author described it as fitting "the iterator model", so the design moved from a one-shot copy to iterator semantics.
  • (e) @lucene.experimental says the API may change, not that it may return repeated ids. This point does make a "won't fix, internal" reply plausible.

How strong the case is: the adjudication records that all verifiers judged it weak, and the cross-model reviews rated it weak. A later cross-model fact check of the brief (OpenAI GPT-5.6) rated it moderate, because the contract is implied rather than written, HnswGraphProvider is experimental, the #15425 entry says "internal" and stock Lucene is unaffected; it still called it a narrow, latent correctness bug. It lowers the impact (minor, latent), not the validity.

Suggested fix

Recommended A. Advance the cursor in DenseNodesIterator.consume

Add cur += numToCopy; before the return in DenseNodesIterator.consume, as ArrayNodesIterator already does. One line; the repro then passes with assertions on and off, and 16 core HNSW test classes (321 tests) and the back-compat HNSW format tests (578 tests) pass.

       for (int i = 0; i < numToCopy; i++) {
         dest[i] = cur + i;
       }
+      cur += numToCopy;
       return numToCopy;
     }
All options and trade-offs3 options ยท 490 words

Fix A and fix B were run through gradle in a scratch worktree of 645d4fe14a20. A is the fix; B adds one javadoc sentence so the contract is written down for the next implementation.

A. Advance the cursor in DenseNodesIterator.consume (validated by the checks) (recommended)

HnswGraph.java:247-250 on main. Backports to 9.5-10.3 put the same line in the nodes == null branch of NodesIterator.consume (9.5) or ArrayNodesIterator.consume (9.6-10.3).

       for (int i = 0; i < numToCopy; i++) {
         dest[i] = cur + i;
       }
+      cur += numToCopy;
       return numToCopy;
     }

Trade-offs

  • Makes the class behave like its two siblings and like the method's own javadoc; no change for Lucene's callers, none of which reads level 0 with consume.
  • The shared DenseNodesIterator.EMPTY (size 0) still throws on consume before touching cur.
  • No cost: one addition per call.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (2026-11-01, removed afterwards): the repro fails on stock main and passes with A, each with -Ptests.asserts=true and =false.
  • With A: 16 core test classes, 321 tests, 0 failures, 12 skipped (6 @Nightly, 6 that apply only to scalar-quantized formats): the repro, every class of org.apache.lucene.util.hnsw (TestOnHeapHnswGraph, TestHnswUtil, TestHnswFloatVectorGraph, TestHnswByteVectorGraph, TestHnswFloat16VectorGraph, TestFilteredHnswGraphSearcher, TestIncrementalHnswGraphMergerBaseSelection, TestNeighborArray, TestNeighborQueue, TestFloatHeap), TestKnnGraph, TestHnswMergeAbort, TestLucene99HnswVectorsFormat, TestLucene99HnswVectorsFormatV0 and TestLucene104HnswScalarQuantizedVectorsFormat.
  • With A: the back-compat HNSW tests (:lucene:backward-codecs:test --tests '*Hnsw*'; those readers also return DenseNodesIterator for level 0): 9 classes, 578 tests, 0 failures, 66 skipped (8 @Nightly, 16 scalar-quantized-only, 42 tests, 3 each for the 14 index versions 9.0.0-9.9.2 that TestInt7HnswBackwardsCompatibility does not support). The full core suite was not run.
  • Earlier, the same line shadowed in: the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests pass, and so do 17 existing tests the refute-lens reviewer ran (TestOnHeapHnswGraph, TestHnswUtil, TestKnnGraph); the same line in 9.5.0's dense branch passes the history test.

B. Fix A plus one javadoc sentence on NodesIterator#consume (validated by the checks)

Adds to the consume javadoc (HnswGraph.java:180): "These are the next integers that {@link #nextInt()} would return, and the iterator advances past them." All three classes then follow the written contract.

Trade-offs

Documentation only on top of A; worth it because the missing sentence is how the dense branch slipped through.

What the checks validated

  • Gradle, same worktree: the repro passes with B (-Ptests.asserts=true), and :lucene:core:check -x test (google-java-format, ecj lint of main and test sources, forbidden APIs, generated-file checksums) passes with B and the repro test in place, so the javadoc sentence and the test are correctly formatted.

C. Document consume as a one-shot copy instead

State that consume does not advance and may be called only once, with a buffer of size().

Trade-offs

Not recommended, not tested: it contradicts the two advancing implementations and PrimitiveIterator.OfInt, and keeps the chunked-loop trap.

Downstream note

Code outside Lucene that reads level 0 of an HNSW graph should walk it with hasNext() / nextInt(), or make one consume() into a size() buffer and then drop the iterator, until a fix ships.

How it was found

Non-formal: code reading. While writing a small model of the HNSW node-iterator API (each iterator as a cursor over a finite list), the finder checked nextInt and consume against all three implementations and saw that only the dense one never moves its cursor. A JUnit on the stock jars confirmed it; independent reproducers and reviewers then checked it.

The method in full155 words

Method: non-formal (code reading during modelling). The hunt unit on the HNSW graph format modelled HnswGraph.NodesIterator as a cursor over a finite sequence: nextInt() returns the element at the cursor and advances it, and consume(dest) copies up to dest.length elements from the cursor and advances it by the returned count. Checking that model against the three implementations showed that ArrayNodesIterator and CollectionNodesIterator follow it and DenseNodesIterator, the level-0 iterator of Lucene's in-memory and stored graphs, does not.

The finder wrote a JUnit on the stock main jars (a drain loop, a consume-then-nextInt check and an ArrayNodesIterator control): 2 of 3 failed with assertions on and off. It was then reviewed by a reviewer from another model family (OpenAI GPT-5.6), reproduced by independent verifiers (one through the stored graph of real indexes, from a package outside Lucene), judged, traced through every release since 9.5.0, and cut to the one-test repro on 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, four Lucene mailing lists, downstream repositories or the web (2026-10-26). Not fixed upstream as of 2026-11-01, and no open PR touches the file.

Searches4 searches

Closest items, none a report of this bug: #11860 (introduced consume()), #12050 and #15425 / #15426 (moved the dense branch unchanged), #15424 (the issue behind the DenseNodesIterator split; no mention of consume), #12651 (getNodesOnLevel javadoc only); open #16815 and #16731 add graph classes that return DenseNodesIterator for level 0 but walk it with nextInt() or consume upper levels only. Third-party code (cuVS, OpenSearch k-NN, a Go port) implements consume with an advance and a Kotlin port copies the bug, with no issue or comment about it. JIRA cannot hold a report: consume() (December 2022) postdates the move to GitHub. Limits: GitHub search reads the first 50 results per query, code search covers only indexed default branches, private forks are not visible; the 2026-11-01 refresh did not repeat the issue search.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; all of GitHub2026-10-26scripted searches (16 issue/PR and 3 commit queries: class names, getNodesOnLevel, consume, "does not advance", "infinite loop"); GitHub searches across all repositories for DenseNodesIterator, NodesIterator consume, getNodesOnLevel consume; code search; the bodies and review comments of #11830, #11860, #12050, #12651, #13260, #15424, #15425, #15426no report
Open PRs2026-10-26changed files of the 94 open apache/lucene PRs (of 413) whose titles mention hnsw, graph, vector, knn, iterator or mergenone touches DenseNodesIterator.consume
Mailing lists, JIRA, downstream, web2026-10-2620 queries over dev@, java-user@, issues@ and commits@; 4 JIRA-archive, 2 downstream-repository and 6 web queries (1,913 distinct hits in total)only notification mirrors of the related PRs
Upstream branches and open PRs (refresh)2026-11-01read-only GitHub API: main HEAD f815e679bf6c, the commits touching HnswGraph.java on main and branch_10_6, the file content, and the changed files of all 409 open PRsunfixed on main and branch_10_6 (last change #15425); no open PR changes HnswGraph.java

History

Introduced in 9.5.0 by #11860 (d0be9ab57c9, 2022-12-07), which added consume() with an advancing array branch and a non-advancing dense branch; #12050 (9.6.0) and #15425 (10.4.0) moved the dense branch unchanged. Every release from 9.5.0 to 10.6.0 and main is affected (executed on 9 releases); 9.4.2 has no consume().

Versions tested6 rows

Introduced by d0be9ab57c9 "GITHUB-11830 Better optimize storage for vector connections (#11860)" (by benwtrent, 2022-12-07, main; backported the same day as d9a331c85bc to branch_9x; closes #11830). It added NodesIterator.consume(int[]); the dense branch (nodes == null, level 0) returns before the cur += numToCopy its array branch runs. Carried forward unchanged by 776149f0f69 (#12050, jmazanec15, 2023-02-07, 9.6.0: dense mode moved into ArrayNodesIterator(int size)) and 08538bbaf0e (#15425, msokolov, 2025-11-13, 10.4.0: split out as DenseNodesIterator). Found with git blame, git log -L241,251, -S'public int consume(int[] dest)' and -G'cur \+= numToCopy' on HnswGraph.java. First release: 9.5.0. The bracketing is release-level, with one version-portable public-API test (stored graph of a 2,000-vector index, chunked consume over level 0, level 1 as the control).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle and standalone); passes with fix A
10.6.0 affectedminimal repro fails with assertions on and off (release jars)
9.5.0, 9.6.0, 9.9.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2; main ea57360912ed, a9779ef3c193 first affected 9.5.0history test fails (level 0 never ends, nextInt() restarts at 0); level-1 control passes; 10.5.2 and main with assertions on and off
9.5.0 and main with the one-line advance fixedhistory test passes
9.4.2 no APIthe test does not compile: NodesIterator has no consume(int[]) (API probe)
upstream main f815e679bf6c, branch_10_6 readthe same code; HnswGraph.java last changed by #15425 (2025-11-13)

Independent checks

Confirmed with high confidence: the finder, an independent reproducer, an intended-behaviour devil's advocate (who reproduced it on a stored index), an adjudication and cross-model reviews (OpenAI GPT-5.6) all reproduced it and agree on the root cause and the one-line fix.

Checks and verdicts339 words

Everyone agrees that the impact is minor and latent: Lucene never calls consume() on level 0.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedJUnit 3 run / 2 failed with assertions on and off on main, and on 10.5.2 and 10.4.0: the drain loop does not end ([0, 1, 2, 0, 1, 2, ...]) and nextInt() after consuming 0, 1, 2 returns 0; the ArrayNodesIterator control passes; 3/0 with the fix.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn public-API test and own test on level 0 of a completed 7-node OnHeapHnswGraph fail on main with assertions on and off and three seeds; the fix passes them and 17 existing tests (TestOnHeapHnswGraph, TestHnswUtil, TestKnnGraph). Intended argument weak.
independent reproducer realOwn JUnits, including the stored graph of a 1,000-document index: 7 run / 5 failed on main (assertions on and off) and 10.5.2, 5 run / 4 failed on 9.12.3, and a 9.5-shape test fails on 9.5.0; both controls pass; 7/0 with the fix.
cross-model review, works-as-designed lens (OpenAI GPT-5.6) real, highOwn contract test fails 2 of 3 on main and 10.5.2 and passes with the fix, as do 4 existing OnHeapHnswGraph tests; supplementary runs fail on 10.4.0 and on 10.3.2 (dense-mode ArrayNodesIterator).
intended-behaviour devil's advocate real, high; latent, minorPublic-API test from a package outside Lucene on the stored graph of a 2,000-vector index fails on main (assertions on and off), 10.5.2 and 9.12.3; level 1 passes; passes with the fix. Built the strongest intended case and rejected it.
adjudication confirmed, highRe-ran the independent reproduction's tests with a new seed (7/5, assertions on and off; 7/0 with the fix), the finder's test and the intended-behaviour check's test (both fail with assertions off).
main refresh and minimal repro still reproducesIdentical per-test results on main a9779ef3c193; the one-test repro fails on 645d4fe14a20 and on 10.6.0 with assertions on and off and passes with fix A.

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,234 words

Finding record

LFV-159, snapshot of 2026-11-06

Registry title
HnswGraph.DenseNodesIterator.consume() (the level-0 node iterator of every HNSW graph) does not advance its cursor, so draining level 0 in chunks with consume() returns the first chunk forever and a nextInt() after consume() starts again at node 0 (latent: Lucene itself never calls consume() on level 0)
Area
util / HNSW graph node iterators (HnswGraph.NodesIterator, DenseNodesIterator)
Priority
Low, rank 1203: DenseNodesIterator.consume() never advances on HNSW level 0, which no Lucene code reads that way.
Impact class
minor
Impact note
Latent: only code outside Lucene that reads level 0 of an HNSW graph (HnswGraph.getNodesOnLevel(0) or getSortedNodes(0), on an OnHeapHnswGraph or on the stored graph from HnswGraphProvider#getGraph, default codec) with consume() in more than one call, or mixed with nextInt()/hasNext(), is hit: a 'while (hasNext()) consume(buf)' loop never ends and mixed use returns duplicate node ids, while one full-size consume() returns the right ids but leaves hasNext() true; Lucene's only consume() caller (Lucene99HnswVectorsWriter.writeMeta) reads upper levels only, no known downstream caller consumes level 0, any graph size triggers it, and every release since 9.5.0 is affected.
Summary
You take the level-0 node iterator of an HNSW graph (getNodesOnLevel(0), a DenseNodesIterator over the node ids [0, size)) and read it in chunks with consume(buf), expecting each call to hand out the next block of ids, as the javadoc ('Consume integers from the iterator') and the two sibling iterator classes do. Instead DenseNodesIterator.consume copies the block but never moves its cursor, so every call returns the same first block, hasNext() stays true and the loop never ends; a nextInt() after consume() also starts again at 0. Lucene's own code never calls consume() on level 0, so indexes and search results are not affected.
Introduced
d0be9ab57c9c (main) / d9a331c85bc (branch_9x backport, the same day), #11860 'GITHUB-11830 Better optimize storage for vector connections' (benwtrent; closes #11830): added NodesIterator.consume(int[]) whose dense branch (nodes == null, the level-0 mode) returns before 'cur += numToCopy' while the array branch of the same method advances [read: git blame, git log -L, -S and -G in the history clone; no commit ever added the advance to the dense branch; exec: release level only, 9.4.2 has no consume() (compile error and API probe), 9.5.0 fails, 9.5.0 with the one-line advance passes]; carried unchanged by #12050 (776149f0f69, 9.6.0: dense mode moved into ArrayNodesIterator(int size)) and #15425 (08538bbaf0e, 10.4.0: dense mode split out as DenseNodesIterator) [read] (2022-12-07 (#11860 merged on main and backported to branch_9x))
First affected version
9.5.0 [exec] (9.4.2 has no NodesIterator.consume [exec: compile error and API probe]; 9.4.0 and 9.4.1 [read])
Affected versions
every release from 9.5.0 to 10.6.0 and main (ea57360912ed, a9779ef3c193 and 645d4fe14a20). [exec] the minimal repro fails on 10.6.0 and main 645d4fe14a20 (-ea and -da); the follow-up's version-portable public-API history test (stored graph of a 2000-document index, chunked consume over level 0, level 1 as control) fails on 9.5.0, 9.6.0, 9.9.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's tests fail on main (-ea and -da), 10.5.2, 9.12.3 and 9.5.0 (a 9.5-shape test); the intended-behaviour check's public-API test on main (-ea and -da), 10.5.2 and 9.12.3; the finder's JUnit on main (-ea and -da), 10.5.2 and 10.4.0; the reviewers' tests on 10.5.2, 10.4.0 and 10.3.2 (dense-mode ArrayNodesIterator); all on JDK 25, also for releases that predate it. [read] the same dense consume block without the advance in every release tag from 9.5.0 to 10.5.2 (9.5.0 in NodesIterator, 9.6.0-10.3.x in ArrayNodesIterator(int size), 10.4.0 on in DenseNodesIterator). Not affected: 9.4.2 and older (no consume()). Not fixed upstream: main, branch_10x, branch_10_6 and branch_10_5 still lack the advance (read-only GitHub API, 2026-10-26).
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26: scripted searches with 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web; 1913 distinct hits, the only high-overlap hits #15424 and #15425), GitHub searches across all repositories for 'DenseNodesIterator', 'NodesIterator consume' and 'getNodesOnLevel consume', GitHub code search for DenseNodesIterator and for getNodesOnLevel with consume(), the bodies and review comments of #11830, #11860, #12050, #12651, #13260, #15424, #15425 and #15426, and the changed files of the 94 open PRs (of 413) whose titles mention hnsw, graph, vector, knn, iterator or merge; the finder's and the intended-behaviour check's earlier runs also found nothing); not fixed upstream: main (head ea57360912ed), branch_10x, branch_10_6 and branch_10_5 still lack the advance, and no open PR changes HnswGraph.java (read-only GitHub API, 2026-10-26); related, not reports of this bug: #11860 (introduced consume()), #12050 and #15425 / #15426 (moved the dense branch unchanged), #15424 (the issue behind the DenseNodesIterator split, no mention of consume), open #16815 and #16731 (new graph classes that return DenseNodesIterator for level 0 but walk it with nextInt() or consume upper levels only); gaps: GitHub search reads the first 50 results per query and treats multi-word queries roughly as phrases, code search covers only indexed default branches, the web search ran 6 queries, private forks and closed-source users cannot be searched
Lucene commit tested
main 645d4fe14a20 (the minimal repro through gradle with -Ptests.asserts=true and =false, stock and with fix A; fix A with the core HNSW suites and the back-compat HNSW format tests; fix B with the repro and :lucene:core:check -x test; standalone runs with assertions on and off and a second seed), the 10.6.0 release jars (SHA-1 checked; minimal repro, assertions on and off), and, by the hunt and its verifiers, main ea57360912ed and a9779ef3c193 and the releases 9.4.2 (no API), 9.5.0, 9.6.0, 9.9.0, 9.12.3, 10.0.0, 10.3.2, 10.4.0 and 10.5.2. HnswGraph.java, OnHeapHnswGraph.java and Lucene99HnswVectorsReader.java are byte-identical at ea57360912ed and 645d4fe14a20; HnswGraph.java was last changed upstream by #15425 (2025-11-13), so upstream main f815e679bf6c (2026-11-01) and branch_10_6 have the same code (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraph.java:94-101 (getNodesOnLevel javadoc), :148-159 (getSortedNodes: level 0 returns a DenseNodesIterator), :161-186 (NodesIterator implements PrimitiveIterator.OfInt; consume javadoc :179-185), :205-214 (ArrayNodesIterator.consume, cur += numToCopy at :212), :241-251 (DenseNodesIterator.consume, no advance), :253-264 (nextInt and hasNext use the same cur), :277-289 (CollectionNodesIterator.consume drains through nextInt)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:267-278 (getNodesOnLevel: DenseNodesIterator for level 0 only)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:662-668 (stored graph: DenseNodesIterator for level 0 only)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:629-636 (writeMeta: the only production consume() call, level > 0, one call with a size() buffer)
  • lucene/core/src/java/org/apache/lucene/codecs/hnsw/HnswGraphProvider.java:26 (@lucene.experimental) and :36 (getGraph); lucene/core/src/java/module-info.java:50 (exports org.apache.lucene.util.hnsw); lucene/CHANGES.txt:1366 (#15425 entry calls NodesIterator 'internal')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraph.java:94-101, :148-159, :161-186, :205-214, :230-265, :277-289
  • lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:267-278
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:662-668
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:323-331, :566-569, :629-637
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/ (lucene91/Lucene91HnswVectorsReader.java:596, lucene91/Lucene91OnHeapHnswGraph.java:176, lucene92/Lucene92HnswVectorsReader.java:506, lucene94/Lucene94HnswVectorsReader.java:559, lucene95/Lucene95HnswVectorsReader.java:588)
Who is affected
  • lucene/core/src/java/module-info.java:50, :76; lucene/core/src/java/org/apache/lucene/codecs/hnsw/HnswGraphProvider.java:17, :26, :39; lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:267-271
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraph.java:101; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene90/Lucene90HnswVectorsReader.java:532-534, lucene90/Lucene90OnHeapHnswGraph.java:212-214
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:3007-3015; lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:348-352; lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java (no getNodesOnLevel, NodesIterator or consume)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraph.java:161-186, :205-214, :241-251, :277-289
  • lucene/CHANGES.txt:1399 (10.4.0 section); lucene/core/src/java/module-info.java:50, :76
  • lucene/core/src/java/org/apache/lucene/util/package-info.java:35; lucene/core/src/java/org/apache/lucene/util/hnsw/package-info.java; lucene/core/src/java/org/apache/lucene/codecs/hnsw/HnswGraphProvider.java:26
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraph.java:205-214, :232, :241-251

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.