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.
- 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.
HnswGraph#getNodesOnLevel(level)returns aNodesIterator, an ordinaryPrimitiveIterator.OfIntover that level's node ids:hasNext()/nextInt()one id at a time, orconsume(int[] dest)to take the next batch into an array; it returns how many ids it wrote.- There are three implementations. In Lucene's in-memory and stored graphs, level 0 gets
DenseNodesIterator, which simply counts 0, 1, 2, ...; upper levels getArrayNodesIteratororCollectionNodesIterator. Code outside Lucene gets a stored graph throughHnswGraphProvider#getGraph, or an in-memory one fromHnswGraphBuilder.
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.- 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. - 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, thenhasNext()is false. Level 1 of the same graph behaves exactly like that. - 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). AnextInt()afterconsume()also starts again at 0. Smallest case:new DenseNodesIterator(4)read twice into anint[2]gives[0, 1]both times. - Why: Lucene's graphs use
DenseNodesIteratorfor level 0, whoseconsumewritescur, cur+1, ...into the buffer but never adds the count tocur. The two other iterator classes, used for the upper levels, do. - 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.
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.
NodesIterator(HnswGraph.java:161-186): "Iterator over the graph nodes on a certain level"; itimplements PrimitiveIterator.OfInt(:166) and declaresconsume(int[] dest): "Consume integers from the iterator and place them into thedestarray. @return The number of integers written todest" (:179-185).DenseNodesIterator(:230-265): "NodesIterator that enumerates [0, size)", with one cursorcurshared byconsume,nextInt(return cur++;) andhasNext(cur < size).- Who returns it:
getNodesOnLevel(0)ofOnHeapHnswGraph(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), andHnswGraph#getSortedNodes(0)(HnswGraph.java:149-150). Upper levels getArrayNodesIteratororCollectionNodesIterator.getNodesOnLevelitself is abstract (HnswGraph.java:101): the lucene90 back-compat graphs throwUnsupportedOperationException, and a customHnswGraphpicks its own iterator.
DenseNodesIterator.consume(dest)(HnswGraph.java:241-251) computesnumToCopy = Math.min(size - cur, dest.length), writesdest[i] = cur + iand returnsnumToCopy. It never runscur += numToCopy.- So
curis where it was: the nextconsumecopies the same block,hasNext()stays true, andnextInt()returns an idconsumealready handed out. - The siblings move their cursor:
ArrayNodesIterator.consumehas the same body pluscur += numToCopy;(:212), andCollectionNodesIterator.consumefillsdestthroughnextInt()(:277-289). Upper levels are therefore correct. - Lucene never notices: its only production
consume()call,Lucene99HnswVectorsWriter.writeMeta(:629-637), runs onlyif (level > 0), makes one call with a buffer ofsize()and drops the iterator. Every in-tree reader of level 0 useshasNext()/nextInt().
new DenseNodesIterator(4)consumed twice into anint[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 awhile (hasNext())loop: every call returns0-6; the test's bound stops it after 286 calls and 2,002 ids (expected 2,000 distinct ids, thenhasNext()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])returns0, 1, 2; the nextnextInt()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 mixesconsume()withnextInt()/hasNext(). The iterator is public API in the exported packageorg.apache.lucene.util.hnsw(module-info.java:50): the stored graph throughHnswGraphProvider#getGraph(@lucene.experimental, in the exported packageorg.apache.lucene.codecs.hnsw,:76), a completedOnHeapHnswGraphfromHnswGraphBuilder(which a customKnnVectorsWriterholds during flush and merge; while it is still being built,getNodesOnLevelthrowsIllegalStateException),getSortedNodes(0)on any graph, ornew 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 tosize()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 leastsize()such a loop is correct).consume()thennextInt()returns duplicates. Oneconsume()into asize()buffer returns the right ids but leaveshasNext()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 usesnextInt(); 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 customHnswGraphimplementation 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
consumeonly 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.
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.
/*
* 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]));
}
}
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
consumefills[2, 3], the next two node ids, and thenhasNext()is false. - Observed
- It fills
[0, 1]again:AssertionError: expected:<[2, 3]> but was:<[0, 1]>, with assertions on and off.
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).
| lucene | main 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() |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
# 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>NodesIteratoris aPrimitiveIterator.OfInt;consumeis 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 thedestarray. @return The number of integers written todest" (: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 fromcur, not from 0; the collection version fills untildestis full; all return the count written. A partial fill points to callers that call again. - History. #11860 (
d0be9ab57c9, 2022-12-07) addedconsumeas one method with two branches: the array branch rancur += numToCopy, the dense branch (nodes == null) returned before it. The review onHnswGraph.javawent 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 fromcur, 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 callsconsumeon a level-0 iterator today; no comment,CHANGES.txtorMIGRATE.mdentry describes a non-advancingconsume. - Independent implementations advance. cuVS, OpenSearch k-NN and a Go port implement
consumewith 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 theorg.apache.lucene.utilpackage-info says "APIs in this package are primarily internal and may change between releases" (util/package-info.java:35). - (b) The
consumejavadoc never literally says "advances". - (c) Every known caller does one full-size
consume()on levels above 0, where the result is right, soconsumeis 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, andHnswGraphProvideris@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; theorg.apache.lucene.util.hnswpackage-info says nothing about internal use.HnswGraph,NodesIterator,DenseNodesIteratorand its constructor are public, have no@lucene.internalor@lucene.experimentaltag, and sit in an exported package. - (b, c) The count, the copy from
curand 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.experimentalsays 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.
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.
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 onconsumebefore touchingcur. - 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=trueand=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 oforg.apache.lucene.util.hnsw(TestOnHeapHnswGraph,TestHnswUtil,TestHnswFloatVectorGraph,TestHnswByteVectorGraph,TestHnswFloat16VectorGraph,TestFilteredHnswGraphSearcher,TestIncrementalHnswGraphMergerBaseSelection,TestNeighborArray,TestNeighborQueue,TestFloatHeap),TestKnnGraph,TestHnswMergeAbort,TestLucene99HnswVectorsFormat,TestLucene99HnswVectorsFormatV0andTestLucene104HnswScalarQuantizedVectorsFormat. - With A: the back-compat HNSW tests (
:lucene:backward-codecs:test --tests '*Hnsw*'; those readers also returnDenseNodesIteratorfor 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 thatTestInt7HnswBackwardsCompatibilitydoes 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.
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.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; all of GitHub | 2026-10-26 | scripted 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, #15426 | no report |
| Open PRs | 2026-10-26 | changed files of the 94 open apache/lucene PRs (of 413) whose titles mention hnsw, graph, vector, knn, iterator or merge | none touches DenseNodesIterator.consume |
| Mailing lists, JIRA, downstream, web | 2026-10-26 | 20 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-01 | read-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 PRs | unfixed on main and branch_10_6 (last change #15425); no open PR changes HnswGraph.java |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (gradle and standalone); passes with fix A |
| 10.6.0 | affected | minimal 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.0 | history 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 | fixed | history test passes |
| 9.4.2 | no API | the test does not compile: NodesIterator has no consume(int[]) (API probe) |
upstream main f815e679bf6c, branch_10_6 | read | the same code; HnswGraph.java last changed by #15425 (2025-11-13) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | JUnit 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, high | Own 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 | real | Own 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, high | Own 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, minor | Public-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, high | Re-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 reproduces | Identical 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. |
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 413 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,234 words
- 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=trueand=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, mainea57360912edanda9779ef3c193and 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.javaandLucene99HnswVectorsReader.javaare byte-identical atea57360912edand645d4fe14a20;HnswGraph.javawas last changed upstream by #15425 (2025-11-13), so upstream mainf815e679bf6c(2026-11-01) andbranch_10_6have the same code (read).
- 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-289lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:267-278lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:662-668lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:323-331, :566-569, :629-637lucene/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-271lucene/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-214lucene/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-289lucene/CHANGES.txt:1399 (10.4.0 section); lucene/core/src/java/module-info.java:50, :76lucene/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
- TestDenseNodesIteratorConsume.java (JUnit, one test):
repro/LFV-159/src/org/apache/lucene/util/hnsw/TestDenseNodesIteratorConsume.java(32 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.
assets/LFV-159/bug.png(413 KB)
![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.](../assets/LFV-159/bug.png)