All findings · Lucene finding · LFV-419 · sorted-ordinalrange-default-after-advanceexact-false

SortedDocValues#ordinalRangeIntoBitSet, called right after advanceExact returned false, marks a document that has no value

On a SORTED doc-values field that some documents lack, ordinalRangeIntoBitSet called right after advanceExact(d) returned false marks the value-less document d by a neighbouring document's ordinal, or throws (EOFException; ArrayIndexOutOfBoundsException on a plain ByteBuffersDirectory). The method is new in 10.6.0 (released 2026-10-29); no Lucene query makes this call.

Summary

Priority
Low, rank 596This new 10.6.0 bulk-API default sets a wrong bit only for an advanceExact-false-then-bulk call pattern that Lucene itself never uses.
Impact
silent wrong results
Since
10.6.0, released 2026-10-29 (#16180, 2026-07-29)introduced 2026-07-29
Affected
main, 10.6.0 (released 2026-10-29) and, by reading, branch_10x; no earlier release
Status
documented
Found by
a bounded-exhaustive check of doc-values iterator moves, then a JUnit

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

ordinalRangeIntoBitSet (new in 10.6.0) fills a bit set with the documents in [fromDoc, toDoc) whose ordinal is in a range. Its default reads the ordinal of the document the iterator is on without checking that the document has one.

Background: how a doc-values iterator moves

  1. A SortedDocValues iterates over the documents that have a value; nextDoc() and advance() only stop on such documents. ordValue() returns the current document's ordinal: its value's rank in the field's sorted dictionary ("a" = 0, "b" = 1).
  2. advanceExact(d) moves to exactly d and returns whether d has a value. After false, docID() is d but there is nothing to read: calling ordValue() then is illegal.
  3. ordinalRangeIntoBitSet(fromDoc, toDoc, minOrd, maxOrd, bits, offset) is a bulk call. The default codec overrides it for some encodings, such as a field every document has; a sparse field with two or more values runs the default in SortedDocValues.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents with the default codec into a SORTED doc-values field f: doc 0 = "a", doc 1 has no value, doc 2 = "b".
  2. On the segment's SortedDocValues, advanceExact(1) returns false: doc 1 has no value, and the iterator now sits on doc 1. On the same iterator, call ordinalRangeIntoBitSet(1, 3, ord("a"), ord("a"), bits, 0): which documents in [1, 3) have "a"?
  3. You expect none: doc 1 has no value and doc 2 has "b". The per-document advanceExact/ordValue loop that the javadoc describes gives exactly that, and so does the same call on a fresh iterator.
  4. Instead bit 1 is set, with no error. The default starts at the current document and calls ordValue() there, which the API calls illegal after a false advanceExact; the codec's sparse iterator still points at doc 0's value, "a".
  5. If no earlier document has a value (doc 0 empty, advanceExact(0)), the same call throws instead: RuntimeException(EOFException) on most directories, ArrayIndexOutOfBoundsException on a plain ByteBuffersDirectory. In this call pair only the first document of the call can be wrong, and only as an extra match.

Who is affected

Only code that calls SortedDocValues#ordinalRangeIntoBitSet itself, on the same iterator, right after advanceExact(d) returned false, on a SORTED field that some documents lack (default codec). Lucene's own queries never do this. Each call gets at most one extra document. Released in 10.6.0 (2026-10-29); also on main.

Mechanism, terms and numbers953 words

SortedDocValues#ordinalRangeIntoBitSet(fromDoc, toDoc, minOrd, maxOrd, bitSet, offset), added in 10.6.0 by #16180, fills bitSet with "the doc IDs in [fromDoc, toDoc) whose ordinals are in [minOrd, maxOrd]", and its javadoc says the default "falls back to per-doc evaluation via advanceExact and ordValue". The default instead starts at the current document whenever docID() >= fromDoc and calls ordValue() there (SortedDocValues.java:117-118 on main 645d4fe14a20). After advanceExact(d) returned false the iterator sits on d, which has no value, and ordValue() is documented as illegal in that state. The default codec's sparse SORTED iterator (two or more distinct values, some documents without one) has no override, and its ordValue() reads with the stale index that advanceExact left: in the tested layouts doc d is judged by the previous valued document's ordinal (in a DENSE block the next one's, or the padding after the last ordinal when none follows), or the call throws when no earlier document has a value (RuntimeException(EOFException), or ArrayIndexOutOfBoundsException on a plain ByteBuffersDirectory). Wrappers that inherit the default (FilterSortedDocValues, MultiDocValues) are affected even with one distinct value. With docs {0: "a", 1: none, 2: "b"}, advanceExact(1) then ordinalRangeIntoBitSet(1, 3, ord("a"), ord("a"), bits, 0) sets bit 1 instead of nothing, with assertions on and off. In this call pair only the start document can be wrong. No Lucene query, sort, facet or merge path makes this call order, and the method is new in 10.6.0 (released 2026-10-29), so the priority is low. Skipping a start document without a value (8 lines) fixes it; the test and 14 doc-values suites pass with the fix.

The mechanism (main 645d4fe14a20)

  1. The cursor after a false advanceExact. "After this method returns, docID() returns target" (DocValuesIterator.java:24-28); the note below names only intoBitSet and docIDRunEnd() as illegal after a false return (:30-31).
  2. The default (SortedDocValues.java:117-122):
   for (int doc = docID() >= fromDoc ? docID() : advance(fromDoc); doc < toDoc; doc = nextDoc()) {
     long ord = ordValue();
     if (ord >= minOrd && ord <= maxOrd) {
       bitSet.set(doc - offset);
     }
   }

With docID() == fromDoc it starts on that document and calls ordValue() (:118), which "is illegal to call ... after advanceExact(int) returned false" (:38-39).

  1. Which iterator runs it. For ordinals stored as one packed block with more than one value (Lucene90DocValuesProducer.java:1473-1477), the dense branch overrides the method with random access (:1548-1555) and is correct; the sparse branch (:1557) has no override, and its ordValue() is (int) values.get(disi.index()) (:1569-1571). The generic iterator (one distinct value, or ords not in one block) delegates to the numeric rangeIntoBitSet (:1654-1661) and answers correctly when called directly. FilterSortedDocValues (so also ExitableDirectoryReader's wrapper), MultiDocValues.MultiSortedDocValues and AssertingSortedDocValues do not override the method, so wrapped iterators run the faulty default, over the generic iterator too: there ordValue() reaches ords.longValue(), which for one distinct value is the constant entry.minValue (:1090-1096), so a wrapper marks the value-less document whenever that ordinal is in range (AssertingSortedDocValues with -ea fails assert exists instead).
  2. What the stale index holds (the tested layouts). After a miss in a SPARSE block, IndexedDISI steps the index back to the last valued document before the target, or leaves -1 if there is none (IndexedDISI.java:607-616); in a DENSE block the index is the rank of the next valued document (:712-713), which is one past the last stored ordinal when no valued document follows. Hence doc 0's ordinal in the example, the next document's ordinal in a DENSE block, the padding after the last ordinal (read as 0) when nothing follows, and values.get(-1), an exception, when nothing precedes. These are the executed layouts, not every IndexedDISI state: a target inside a 65,536-document block with no values (advanceBlock, :495-516), for example, was not run.
  3. Only the start document. After it the loop moves with nextDoc(), which lands only on documents with a value, so later bits are right and no later valued document is dropped. Separate, pre-existing behaviour, not this bug: when docID() > fromDoc the default starts at docID() and never evaluates [fromDoc, docID()), valued documents included; the fixes below keep that (a forward-only iterator cannot go back).

Variants (executed)

layout (default codec)callexpectedactual
{0: a, 1: none, 2: b}advanceExact(1) false, then range [ord(a), ord(a)] over [1, 3){}{1} (doc 0's ordinal)
{0: none, 1: a, 2: b}advanceExact(0) false, then range [ord(a), ord(a)] over [0, 3){1}RuntimeException(EOFException) (on MMapDirectory "read past EOF (pos=2305843009213693951)" = (-1L) >>> 3; the same on NIOFSDirectory and MockDirectoryWrapper); ArrayIndexOutOfBoundsException: Index 536870912 out of bounds for length 1 on a plain ByteBuffersDirectory
DENSE block: 5,000 docs, docs 0 and 1 without a value, doc 2 = badvanceExact(0) false, then range [ord(b), ord(b)] over [0, 4){2}{0, 2} (the next valued doc's ordinal)
DENSE block, nothing after d: 5,000 docs, docs 0..4499 = a, b alternating, 4500..4999 without a valueadvanceExact(4600) false, then range [ord(a), ord(a)] over [4600, 5000){}{4600}, no exception (one past the last stored ordinal; the padding reads as 0 = a)
40 random sparse layouts (seed DEADBEEF)for every value-less d: advanceExact(d), then a range from d390 checks176 wrong bitsets (always one extra bit at d), 22 exceptions; the unpositioned control 390 of 390 right (seed FC41906: 178 wrong and 18 exceptions in 436)
wrapped field with one distinct value, {0: a, 1: none} (FilterSortedDocValues and ExitableDirectoryReader views; the finder's harness)advanceExact(1) false, then range [ord(a), ord(a)] over [1, 2){}{1}
Exposure and likelihood280 words
  • No in-tree caller reaches it. The only caller, DocValuesRangeIterator.BulkSortedRangeIterator#intoMaybeBlock, passes from = Math.max(blockStart, docID()) (DocValuesRangeIterator.java:513-519) on an iterator it moves with advance() only (advanceDisi, :296-301), so docID() always has a value there. An executed probe (8,400 query executions per run over 14 random skip-indexed indexes, raw, asserting and exitable readers, assertions on and off) found 0 mismatches while the default ran about 13,000 times per reader; the direct call pair on the same indexes misbehaved in 250 of 327 trials. Open PR #16897 adds a second caller, which also positions with advance().
  • Who can hit it. Application code that probes a document with advanceExact and then bulk-evaluates from it; the test of open PR #16835 makes the analogous call pair on the numeric sibling. Default codec and configuration; any index size (three documents suffice).
  • Effect. Silent wrong results: one extra document per call, judged by a neighbouring document's ordinal from the same field and segment, or an exception (RuntimeException(EOFException), or ArrayIndexOutOfBoundsException on a plain ByteBuffersDirectory). No hang, no resource use, no write.
  • Not affected. Unpositioned iterators, iterators moved by nextDoc()/advance() or by an advanceExact that returned true; dense SORTED fields; the unwrapped generic iterator (one distinct value); the default implementations of NumericDocValues#rangeIntoBitSet and SortedNumericDocValues#rangeIntoBitSet, which are per-doc advanceExact loops. Not every codec override of those siblings is safe: the sparse multi-valued Lucene90 SortedNumericDocValues#rangeIntoBitSet override (Lucene90DocValuesProducer.java:2202-2255, start check :2210-2213) has the same flaw; that is LFV-415, classed known because open PR #16835 guards it for table-encoded entries.
  • Releases. 10.6.0 (where #16180 is listed), released 2026-10-29 from the RC1 bits, which contain the bug. Priority low.

Minimal repro

The test shows that on a default-codec segment with SORTED docs {0: "a", 1: no value, 2: "b"}, advanceExact(1) returns false and then ordinalRangeIntoBitSet(1, 3, ord(a), ord(a), bits, 0) on the same iterator marks doc 1.

TestSortedOrdinalRangeAfterFalseAdvanceExact.java (JUnit test)37 lines
package org.apache.lucene.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedDocValuesField;
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;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.FixedBitSet;

public class TestSortedOrdinalRangeAfterFalseAdvanceExact extends LuceneTestCase {

  public void testDocWithoutValueIsNotMarked() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        for (String value : new String[] {"a", null, "b"}) {
          Document doc = new Document();
          if (value != null) {
            doc.add(new SortedDocValuesField("f", new BytesRef(value)));
          }
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        SortedDocValues dv = getOnlyLeafReader(reader).getSortedDocValues("f");
        int ordA = dv.lookupTerm(new BytesRef("a"));
        assertFalse(dv.advanceExact(1));
        FixedBitSet bits = new FixedBitSet(3);
        dv.ordinalRangeIntoBitSet(1, 3, ordA, ordA, bits, 0);
        // expected: doc 1 (no value) is not marked; actual: marked with doc 0's ordinal
        assertFalse("doc 1 has no value but was marked", bits.get(1));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-419/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestSortedOrdinalRangeAfterFalseAdvanceExact
Expected vs actual
Expected: doc 1 not marked (it has no value; the javadoc promises only docs whose ordinal is in range); actual (-ea and -da): bit 1 is set from doc 0's ordinal (doc 1 has no value but was marked).

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

More files, environment, full output38 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: doc 1 has no value but was marked
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.index.TestSortedOrdinalRangeAfterFalseAdvanceExact.testDocWithoutValueIsNotMarked(TestSortedOrdinalRangeAfterFalseAdvanceExact.java:33)

Why it looks unintended

  • The javadoc promises the documents in [fromDoc, toDoc) with an ordinal in range, by per-doc advanceExact/ordValue evaluation, which is right here.
  • The default itself calls ordValue() where the API forbids it, not the caller.
  • From the same state the dense override, the unwrapped single-value path and the numeric siblings' defaults answer correctly; the sparse SORTED path marks doc 1.
The full argument254 words
  • Contract. SortedDocValues.java:99-105 (the method's javadoc); :38-39 (ordValue()); DocValuesIterator.java:30-31 lists only intoBitSet and docIDRunEnd() as illegal after a false advanceExact. The method has no @lucene.internal or @lucene.experimental tag, while DocIdSetIterator#intoBitSet is @lucene.internal (DocIdSetIterator.java:203).
  • Encoding-dependent answers. An executed consistency probe on one codec: a SORTED field with one distinct value (unwrapped) and a NumericDocValues field give the per-doc answer from the same state; a sparse SORTED field with two values marks the value-less document.
  • The test framework flags it. AssertingSortedDocValues.ordValue asserts exists (AssertingLeafReader.java:1084), so the default trips Lucene's own assertion.
  • History. The first revision of #16180 (f5a83a8d11a) was the per-doc advanceExact loop. A reviewer (jimczi) asked "Why advanceExact per-doc here instead of iterating with advance/nextDoc? This is really just a range scan over the docs that have a value, which is the same pattern as the default DocIdSetIterator.intoBitSet and the old BulkOrdinalRangeIterator." They gave a performance reason (per-doc advanceExact walks every gap document of a sparse field) and proposed today's loop; revision 03ccba9729b adopted it and left the javadoc unchanged. Nobody discussed a start state reached by a false advanceExact.
  • Why tests missed it. Three tests reach the method, all through DocValuesRangeIterator, which moves the iterator with advance() and never leaves it on a false advanceExact. TestDocValuesRangeIteratorIntoBitSet#testOrdinalRangeIntoBitSetMatchesPerDocEvaluation and #testOrdinalIntoBitSetMatchesLinearScan index real documents that all have a value; TestDocValuesOrdinalRangeIterator#testSortedDocValuesIntoBitSetMatchesLinearScan runs the stock default on a mock iterator in which some documents have no value, but never from a false advanceExact.
The devil's-advocate check256 words

Strongest case (the intended-behaviour check, the adjudication, the prior-report search): the default was modelled on DocIdSetIterator#intoBitSet on purpose: jimczi's #16180 review chose the scan over present documents so that sparse fields do not walk every gap document. In #14529 a maintainer (jpountz) wrote "It shouldn't be legal to call intoBitSet after advanceExact returns false", which became the note on advanceExact; that settled the rule for DocIdSetIterator#intoBitSet, not for every bulk method. So this call order could be called misuse, fixed by adding the method to that note; the method is also new in 10.6.0, no in-tree caller reaches the state, and the default already skips [fromDoc, docID()) when docID() > fromDoc, an implied precondition (the hunt's credibility audit).

Why it fails: intoBitSet is relative to docID(), while this method takes an explicit absolute fromDoc and its javadoc still describes the per-doc loop; nothing documents a precondition; the answer changes with the encoding; one layout throws a codec exception (EOFException, or ArrayIndexOutOfBoundsException on a plain ByteBuffersDirectory) rather than a clean argument check. Open PR #16835 (kkewwei, approved by sgup432, not merged) accommodates this call order in sibling code: for table-encoded entries it adds exactly this guard to the Lucene90 numeric and sorted-numeric overrides, and its test calls advanceExact(fromDoc) before the bulk call; the PR is framed as a speed-up and does not call the sequence a public API contract. Residual risk: maintainers may prefer to document a precondition; the stale javadoc and the codec exception would still need fixing then.

Suggested fix

Recommended Skip a start document that has no value

In the default, when the iterator already sits at or past fromDoc, check the current document with advanceExact(docID()) and skip it with nextDoc() if it has no value (SortedDocValues.java, +8/-1). The test and 14 doc-values suites (728 tests) pass, with assertions on and off.

-    for (int doc = docID() >= fromDoc ? docID() : advance(fromDoc); doc < toDoc; doc = nextDoc()) {
+    int doc = docID();
+    if (doc < fromDoc) {
+      doc = advance(fromDoc);
+    } else if (doc < toDoc && advanceExact(doc) == false) {
+      // positioned by advanceExact() on a doc without a value: skip it
+      doc = nextDoc();
+    }
+    for (; doc < toDoc; doc = nextDoc()) {
       long ord = ordValue();
All options and trade-offs3 options · 563 words

Two options were built and run in a scratch worktree of 645d4fe14a20; a documentation-only change is not recommended alone.

Skip a start document that has no value (validated by the checks) (recommended)

The finder's fix, with the guard written as doc < toDoc, the guard shape kkewwei uses in open PR #16835 (table-encoded numeric overrides). It keeps the scan over documents with a value that jimczi asked for in the #16180 review, and the existing acceptance of docID() > fromDoc; like the stock code, it then starts at docID() and does not evaluate [fromDoc, docID()).

-    for (int doc = docID() >= fromDoc ? docID() : advance(fromDoc); doc < toDoc; doc = nextDoc()) {
+    int doc = docID();
+    if (doc < fromDoc) {
+      doc = advance(fromDoc);
+    } else if (doc < toDoc && advanceExact(doc) == false) {
+      // positioned by advanceExact() on a doc without a value: skip it
+      doc = nextDoc();
+    }
+    for (; doc < toDoc; doc = nextDoc()) {
       long ord = ordValue();

Trade-offs

  • One advanceExact(docID()) per call when the iterator is already at or past fromDoc (legal: target >= docID()); for the in-tree caller one cheap check per MAYBE block. No benchmark was run.
  • Caveat to state (the adjudication, from the cross-model refute review): flush-time BufferedSortedDocValues and some merge views throw UnsupportedOperationException from advanceExact, but they also throw from advance(), so the stock default already fails on them when docID() < fromDoc; none of them is passed to this method in-tree.

What the checks validated

  • Stock + test: fails with assertions on and off; + fix: passes with assertions on and off.
  • + fix: 14 suites, 728 tests (65 skipped), 0 failures, assertions on and off: TestDocValuesRangeIteratorIntoBitSet, TestDocValuesRangeIterator, TestDocValuesOrdinalRangeIterator, TestDocValuesOrdinalSetIterator, TestDocValuesQueries, TestSkipBlockRangeIteratorBitSetOperations, TestBulkScorerConsistency, TestSortedSkipperScorerSupplier, TestSortedSetDocValuesSetQuery, the three TestLucene90DocValuesFormat* suites, TestMultiDocValues and the new test; :lucene:core:check -x test passes.
  • With the patched file shadowed, the finder's 2 tests (incl. the EOFException layout) and the independent reproduction's 7 tests (random sparse layouts, the DENSE block, MMapDirectory) pass. Not run: the full test suite, nightly tests.
  • The finder's own form of the fix, shadowed together with the numeric sibling's fix, took the harness class LEAD-B from 1,770 mismatches (1,350 on SORTED views, this bug; 420 on SORTED_NUMERIC views, LFV-415) to 0.

The per-doc advanceExact loop the javadoc describes

for (int doc = Math.max(fromDoc, docID()); doc < toDoc; doc++) { if (advanceExact(doc)) { ... } }, as in the first revision of #16180 (plus Math.max, so docID() > fromDoc stays legal; like the recommended fix, it then starts at docID()). Built and run: the test and the same 14 suites pass with assertions on.

Trade-offs

Matches the javadoc word for word and the numeric siblings, but visits every document in the range instead of only those with a value, the cost the #16180 review asked to avoid on sparse fields.

Document a precondition (not recommended alone)

Add ordinalRangeIntoBitSet to the "illegal after advanceExact returned false" note on DocValuesIterator#advanceExact, as #14529 did for intoBitSet.

Trade-offs

Also needs the javadoc's "per-doc evaluation via advanceExact" sentence fixed, and leaves a silent extra hit or a codec exception for callers who miss the note, while the dense override and the unwrapped single-value path keep answering correctly.

Downstream note

No public downstream code calls this method yet (it is new in 10.6.0, released 2026-10-29); code that probes a document with advanceExact and then bulk-evaluates from it would get one extra hit, or an exception, per call.

How it was found

A bounded-exhaustive check: a harness made every legal sequence of up to three iterator moves on tiny segments, for 26 doc-values and norms views, and compared each step with a fresh nextDoc() enumeration. The mismatch class "advanceExact false, then a bulk range call" led here; it was then reduced to a JUnit on the public API.

The method in full198 words

Method: bounded-exhaustive translation validation (semi-formal), a hunt unit. The harness drives the stock jar through every legal move sequence of depth up to 3 over nextDoc, advance, advanceExact, intoBitSet, docIDRunEnd, intoArray, longValues, rangeIntoBitSet and ordinalRangeIntoBitSet on segments of up to 5 documents (skip index on and off), plus random sequences on 70,000-document segments covering SPARSE, DENSE and ALL blocks and two-cursor interleavings, for 26 doc-values and norms views, and compares each step with a fresh nextDoc() enumeration of the same view: about 990M sequences over two batches. Mismatch class LEAD-B (advanceExact(t) == false, then a bulk range call starting at t) had 1,770 mismatches in a small-scope run: 1,350 on SORTED views (this bug) and 420 on SORTED_NUMERIC views (the numeric sibling LFV-415); 0 with this fix and the sibling's fix shadowed. The SORTED mismatches came from the segment's own iterator, its merge instance, the ParallelLeafReader, FilterSortedDocValues, ExitableDirectoryReader and AssertingLeafReader views (that run recorded wrong bits there, not an assertion failure), and the multi-segment views. The finder then wrote a public-API JUnit on the stock main jars; this doc's test is a one-method version of it.

Prior reports, history and checks

Prior reports

No prior report found: 196 queries on 2026-10-26 (GitHub issues, PRs and commits, four mailing lists, the JIRA archive, downstream repositories, the web), refreshed on 2026-10-29. Related: #14529 made intoBitSet illegal after a false advanceExact; open PR #16835 guards the same state in the numeric overrides.

Searches5 searches

Related, not reports of this bug: #16180 introduced it (the review swapped the per-doc loop for the scan; nobody discussed this start state); #14529 resolved the same kind of start state for intoBitSet by declaring the call illegal, and the resulting note does not name this method; open #16835 (kkewwei) adds the advanceExact(currentDoc) == false guard to the Lucene90 numeric and sorted-numeric overrides, for table-encoded entries only (it is why the numeric sibling LFV-415 is classed known); open #16869 adds the SortedSetDocValues sibling with a correct per-doc default; open #16897 adds a second caller that positions with advance().

Judgment call recorded in the registry: under the same-fix-site rule this bug stays new; counting "the same hazard is guarded elsewhere" as partial overlap would make it known.

Since the open-PR file scan: open PR #16911 (javanna, "Decommission branch 10x", opened 2026-10-26 against branch_10x, 7,630 changed files) removes branch_10x's files, SortedDocValues.java among them. It does not touch main or branch_10_6, but a re-scan of open PRs by file would list it.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr)2026-10-26three scripted prior-report searches (145 queries incl. lists and JIRA) and 42 manual searches: ordinalRangeIntoBitSet with advanceExact, ordValue, EOFException; "advanceExact returned false"; "docID() >= fromDoc"; "falls back to per-doc evaluation"; full threads of 37 PRs and issuesno report; #16180, #14529, #16835, #16869, #16897 are related
Open apache/lucene PRs2026-10-26changed-file lists of all 411 open PRsnone touched SortedDocValues.java then; #16911 ("Decommission branch 10x"), opened later that day against branch_10x, removes it there
Mailing lists and JIRA archive2026-10-26dev@, java-user@, issues@, commits@ through a scripted prior-report search; JIRA for ordinalRangeIntoBitSet, rangeIntoBitSet, intoMaybeBlockno report (the method postdates the JIRA era)
Web2026-10-264 web searchesnothing relevant
Refresh2026-10-29upstream branch heads; issues and PRs naming ordinalRangeIntoBitSet; items updated since 2026-10-25 for advanceExact, SortedDocValues, ordValue, "ordinal range"; the files of #16835, #16869, #16897 (the open-PR file scan was not repeated)main still 645d4fe14a20; no new item; none of the three PRs touches SortedDocValues.java

History

Introduced with the method by #16180 (04d33b4ce5d, merged 2026-07-29, milestone 10.6.0); its first revision was a correct per-doc loop. Affects 10.6.0 (released 2026-10-29) and main; no earlier release has the method.

Versions tested7 rows

#16180 "add bulk intoBitSet for SortedDocValues ordinal ranges" (iprithv, merged by jimczi) is 04d33b4ce5d on main and 8c58bbf22ba on branch_10x, contained in branch_10_6; CHANGES lists it in the 10.6.0 section. It added the public method and its default together, with overrides for the dense and generic SORTED iterators but none for the sparse one. Inside the PR, revision f5a83a8d11a (2026-07-04) used a per-doc advanceExact loop; after a review question, 03ccba9729b (2026-07-27) switched to the docID() >= fromDoc ? docID() : advance(fromDoc) start and left the javadoc unchanged. No later commit touched the method.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails, assertions on and off (gradle and standalone)
branch_10_6 4ae946e5a9b (10.6.0 RC1) affectedthe test fails on jars built at this revision (assertions off); the finder's 2 tests fail 2 of 2 (assertions on)
main ea57360912ed, branch_10_6 cb17f8f865c affectedthe finder's tests fail 2 of 2, assertions on and off
04d33b4ce5d (#16180) vs its parent 62f29b6c915 bisect#16180 fails 2 of 2; its parent has no such method (compile error)
10.5.2, 10.5.1, 9.12.3 method absentthe test does not compile: the method does not exist
branch_10x 657014b3c579 readthe same lines (by reading, not run)
with the fix (main 645d4fe14a20) not affectedthe test and 14 doc-values suites pass

Independent checks

The finder, the independent reproduction, the intended-behaviour check and an adjudication rate it real (adjudication: confirmed, high), as does the intended-lens cross-model review (the cross-model review, reading only); the refute-lens review, also reading only, returned a low-confidence procedural "not real" but agreed on the mechanism. Fact checks found this doc right in substance; their corrections are applied.

Checks and verdicts395 words

Every check re-ran a repro on the stock jars. The reviewer from another model family (OpenAI GPT-5.6) could not execute code in either lens; its execution evidence is a separate re-run of the finder's repro.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateHarness class LEAD-B: 1,770 mismatches (1,350 on SORTED views, this bug; 420 on SORTED_NUMERIC views, the sibling LFV-415), 0 with this fix and the sibling's fix shadowed; JUnit fails 2 of 2 on main with -ea and -da, passes with the fix.
cross-model review, refute lens not real, low, procedural only"not a substantive refutation": it could not run code, agreed on the root cause, judged the inputs in contract and flagged the narrow reach and the fix caveat; a separate run re-ran the repro (2 of 2 fail).
Independent reproducer realOwn public-API JUnit: 5 of 7 fail on main (-ea, -da, two seeds), 2 controls pass; found the DENSE-block variant; 7 of 7 pass with the fix.
Cross-model review, intended lens real, medium (reading only)No required iterator state in the javadoc; the default makes the forbidden ordValue() call; the numeric siblings' defaults correct.
Devil's advocate real, low impactExecuted consistency probe: encoding-dependent answers from the same call pair; the "modelled on intoBitSet" case does not survive the explicit fromDoc.
adjudication confirmed, highFresh seed: finder 2 of 2 fail (-ea, -da), the independent reproduction 5 of 7 fail, 7 of 7 with the fix; duplicate of none.
this doc's runs reproduced, fix validatedThe one-method test on main 645d4fe14a20 (gradle and standalone, -ea, -da) and the RC1 jars; the fix with 14 suites and check; the alternative fix with the same suites.
Independent fact and diagram checks right in substance; errors fixedA fact check re-ran the test (gradle and standalone, -ea and -da, the RC1 jars, 10.5.2), fix A, option B and 4 suites, and added probes; a cross-model fact check (OpenAI GPT-5.6, reading only) re-read every citation; a diagram check compared every frame with the code. Their corrections (the exception by Directory, the wrapper and sibling scope, the [fromDoc, docID()) caveat, the tests that reach the method, the harness split, the query count, the review tallies) are applied.

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

Finding record

LFV-419, snapshot of 2026-11-06

Registry title
SortedDocValues#ordinalRangeIntoBitSet's default implementation, called after advanceExact(d) returned false, reads ordValue() for doc d, which has no value, so it marks d using a neighbouring document's ordinal (or throws EOFException)
Area
index / SortedDocValues bulk ordinal range (default implementation; Lucene90 sparse SORTED iterator)
Priority
Low, rank 596: This new 10.6.0 bulk-API default sets a wrong bit only for an advanceExact-false-then-bulk call pattern that Lucene itself never uses.
Impact class
silent wrong results
Impact note
Only application code that calls the public, untagged SortedDocValues#ordinalRangeIntoBitSet (new in 10.6.0, released 2026-10-29) itself, from a document d on which advanceExact(d) just returned false on the same iterator, over a SORTED field that some documents lack, is hit, on the default codec; each such call marks at most one extra document (d, judged by the previous valued document's ordinal in a SPARSE block or the next one's in a DENSE block) or throws RuntimeException(EOFException) when no earlier document in a SPARSE block has a value; no Lucene query, sort, facet or merge path makes this call pair (the only in-tree caller positions the iterator with advance()), ordinary scale, 10.6.0 (released 2026-10-29) and main only.
Summary
On a default-codec index with docs {0: 'a', 1: no value, 2: 'b'} in a SORTED doc-values field, you call advanceExact(1) on the field's SortedDocValues (it returns false) and then ordinalRangeIntoBitSet(1, 3, ord(a), ord(a), bits, 0) on the same iterator. You expect no bit, because neither doc 1 nor doc 2 has the value 'a', and that is what the per-document advanceExact/ordValue evaluation the javadoc describes returns. Instead the default starts at docID() == 1, calls ordValue() there (which the API calls illegal after a false advanceExact), gets doc 0's ordinal and marks doc 1; with {0: no value, 1: 'a', 2: 'b'} and advanceExact(0), the same call throws RuntimeException(EOFException).
Introduced
04d33b4ce5dd9f0466ce7b525c3ef5a770925eb2 (GITHUB#16180 'add bulk intoBitSet for SortedDocValues ordinal ranges', by GitHub iprithv, merged by jimczi, milestone 10.6.0; branch_10x cherry-pick 8c58bbf22ba, contained in branch_10_6) added both the public method and its faulty default [exec: the introducing commit fails the repro 2 of 2 with -ea and -da; its parent 62f29b6c915 has no such method (compile error); read: git blame, git log -L and -S on SortedDocValues.java list only 04d33b4ce5d]. Inside the PR, the first revision f5a83a8d11a (2026-07-04) used a correct per-doc advanceExact loop; after a review question about using advance/nextDoc instead, revision 03ccba9729b (2026-07-27) switched to the 'docID() >= fromDoc ? docID() : advance(fromDoc)' start and left the javadoc unchanged. Not a regression of a released API: the bug is as old as the method (2026-07-29 (04d33b4ce5d on main; cherry-pick 8c58bbf22ba on branch_10x the same day))
First affected version
10.6.0, released 2026-10-29 (#16180 is in the 10.6.0 section of CHANGES, and the branch_10_6 head cb17f8f865c fails the repro [exec]; lucene-core 10.6.0 was not yet published on 2026-10-26); no earlier release contains the method
Affected versions
main ea57360912ed [exec, -ea and -da]; branch_10_6 head cb17f8f865c (10.6.0-SNAPSHOT) [exec, -ea and -da]; introducing commit 04d33b4ce5d [exec, -ea and -da]; branch_10x head 42df6b34707 (10.7.0-SNAPSHOT) and upstream main cf398bfb142 [read, same lines, 2026-10-26]; not applicable: 9.12.3, 10.5.1, 10.5.2 and the parent 62f29b6c915 (method absent; the repro does not compile) [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-26, read-only, 196 logged queries: three scripted prior-report searches (145 queries) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 42 manual GitHub searches; full threads of 37 PRs and issues read; the changed-file lists of all 411 open PRs scanned, none touches SortedDocValues.java). Not fixed upstream: main cf398bfb142, branch_10x and branch_10_6 keep the code of #16180. Related, not reports of this bug: #16180 (introduced it; the review swapped the per-doc advanceExact loop for an advance/nextDoc scan, nobody discussed the advanceExact()==false start state); #14529 (2025; for intoBitSet the same kind of start state was resolved by declaring the call illegal, the note on DocValuesIterator#advanceExact does not name ordinalRangeIntoBitSet); open #16835 (GitHub kkewwei, approved, not merged) adds the same advanceExact()==false guard to Lucene90 numeric rangeIntoBitSet overrides only, never touches or mentions SortedDocValues; open #16869 (SortedSetDocValues sibling, per-doc advanceExact default, correct here); open #16897 (a second caller that positions with advance()); #8922, #9226, #9186, #11432 (older, unrelated)
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28, upstream main on 2026-10-29): the repro test through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock, with the recommended fix and with the alternative fix; jars built at branch_10_6 4ae946e5a9b (10.6.0 RC1), assertions off; the 10.5.2 release jars (the method is absent, the test does not compile). The hunt's earlier runs (finder, verifiers, adjudication, follow-up, including the introducing commit 04d33b4ce5d, its parent, branch_10_6 cb17f8f865c, 10.5.1 and 9.12.3) used main ea57360912ed, where every cited source file is identical.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java:36-43 (ordValue: 'It is illegal to call this method after advanceExact(int) returned false'), :99-123 (javadoc: 'Fills bitSet with the doc IDs in [fromDoc, toDoc) whose ordinals are in [minOrd, maxOrd]'; 'The default implementation falls back to per-doc evaluation via advanceExact and ordValue'; code :117 'for (int doc = docID() >= fromDoc ? docID() : advance(fromDoc); ...', :118 'long ord = ordValue();')
  • lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33 (advanceExact: target >= docID(), docID() == target afterwards; after false only intoBitSet and docIDRunEnd are illegal)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1549 (dense SORTED override, random access, correct), :1566-1571 (sparse SORTED iterator: no override; ordValue() = values.get(disi.index())), :1655 (generic SORTED override, correct)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:597 (SPARSE advanceExactWithinBlock: on a miss index stays on the previous valued doc, -1 if none), :694 (DENSE advanceExactWithinBlock: index = rank of the next valued doc)
  • lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java:296 (advanceDisi positions with advance() only), :490-518 (the only in-tree caller: from = max(blockStart, docID()), then sortedValues.ordinalRangeIntoBitSet)
  • lucene/CHANGES.txt:438, :635 (#16180 in the 10.6.0 section)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java:37-44, :99-123
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:24-33
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1473-1477, :1548-1555, :1557-1607, :1654-1661
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:485-493, :597-623, :694-715
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/FilterSortedDocValues.java:24-90 (no ordinalRangeIntoBitSet override); index/ExitableDirectoryReader.java:205; index/MultiDocValues.java:684
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1090-1096 (bitsPerValue 0: longValue() = entry.minValue); codecs/lucene90/IndexedDISI.java:495-516 (advanceBlock)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java:296-301, :493-520
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/NumericDocValues.java:113-140; index/SortedNumericDocValues.java:40-74
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:2202-2255 (the sibling override, LFV-415)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java:38-39, :99-105
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/DocValuesIterator.java:30-31; search/DocIdSetIterator.java:186-210
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:1082-1088
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestDocValuesRangeIteratorIntoBitSet.java:688-753
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestDocValuesRangeIteratorIntoBitSet.java:591-641; TestDocValuesOrdinalRangeIterator.java:40-42, :116-178, :285-288
Prior reports
  • GitHub apache/lucene#16911 (gh pr view, read-only, 2026-10-29: title, base branch_10x, created 2026-10-26, 7,630 changed files)
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:653 (10.6.0 section from :456)
  • a Lucene clone: git show 4ae946e5a9b / 657014b3c579 :lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java (lines 117-118)

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe. Its fix section shows the finder's form of the fix (guard start != NO_MORE_DOCS); the recommended fix above is the same change with the guard written as doc < toDoc, and both were run.

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.