All findings · Lucene finding · LFV-1049 · indexsort-range-int-sort-long-bound-cast

An index-sort range query on an int-sorted field silently misses or adds documents when a bound is Long.MIN_VALUE, Long.MAX_VALUE or any other value outside the int range

On an index sorted by an int field, IndexSortSortedNumericDocValuesRangeQuery cuts its long bounds to int, so "f <= 10" written as [Long.MIN_VALUE, 10] is searched as [0, 10] and silently returns 1 of the 2 matching documents. Every core release since 9.5.0 has it, 10.6.0 included.

Summary

Priority
Medium, rank 83Both give deterministic silent wrong hits and counts in a rare index-sort setup, and the natural Long.MIN_VALUE/Long.MAX_VALUE open-end idiom makes this more than the type mistake behind LFV-779, but it needs the expert query built by hand.
Impact
silent wrong results
Since
9.5.0 (first core release; the doc-values cast was in the sandbox class from 9.2.0)introduced 2022-04-04 · about 4 years
Affected
every core release from 9.5.0 to 10.6.0, and main
Status
documented
Found by
code reading while writing a Z3 model of the bound handling

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

The query takes long bounds, but on an int sort it narrows them with a plain (int) cast before its binary search: setTopValue((int) topValue) on the doc-values path, IntPoint.pack((int) bound) on the 4-byte points path. A bound outside the int range keeps only its low 32 bits, so the search returns the wrong doc-ID range.

Background: the index-sort range shortcut

  1. An index can be sorted by a field (IndexWriterConfig.setIndexSort). Each segment then stores its documents in that field's order, so the documents that match a range on the field sit next to each other.
  2. IndexSortSortedNumericDocValuesRangeQuery uses this: it binary-searches the first and the last matching document and returns the doc-ID range between them, instead of checking every document. When the shortcut does not apply, it runs the fallback query you pass in, which must match the same documents.
  3. Its bounds are inclusive longs. The doc-values range queries write an open end as Long.MIN_VALUE or Long.MAX_VALUE, and this class's own rewrite() does the same.
Summary of the bug: the public-API code (an INT index sort; values -5, 3 and 20; a query and its fallback over [Long.MIN_VALUE, 10]); the results on main: the fallback finds 2 documents, the query 1, count() 1, and "f >= 0" as [0, Long.MAX_VALUE] finds 0 instead of 2; a number line where the range should cover -5 and 3 but main searches [0, 10]; the doc IDs in index-sort order, expected [0, 2) and main [1, 2); line 633, fieldComparator.setTopValue((int) topValue).
The three documents in index-sort order and the binary search for "f <= 10": the (int) cast turns Long.MIN_VALUE into 0, so the query returns the doc-ID range [1, 2) instead of [0, 2) and misses -5. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Sort the index by new SortField("f", SortField.Type.INT) and add three documents whose doc value f is -5, 3 and 20.
  2. Ask for "f <= 10": new IndexSortSortedNumericDocValuesRangeQuery("f", Long.MIN_VALUE, 10, fallback), writing the open lower end as Long.MIN_VALUE, the way the doc-values range queries document it. You expect 2 hits, -5 and 3, the same as the fallback NumericDocValuesField.newSlowRangeQuery("f", Long.MIN_VALUE, 10).
  3. You get 1 hit, and count() says 1: the document with -5 is missing. No error.
  4. Why: the query binary-searches the sorted doc values, and for an int sort it hands each bound to the comparator as (int) bound. (int) Long.MIN_VALUE is 0, so it searches 0 <= f <= 10. (int) Long.MAX_VALUE is -1, so "f >= 0" written as [0, Long.MAX_VALUE] finds nothing (0 instead of 2).
  5. An IntField (4-byte points) takes the same cast on its points path: the same wrong counts, extra hits for some bounds, or an IllegalArgumentException. A bound inside the int range is not changed by the cast.

Who is affected

Applications that sort the index by an int field (SortField.Type.INT, or SortedNumericSortField with Type.INT on an IntField) and build this query themselves with a bound outside the int range, most naturally Long.MIN_VALUE or Long.MAX_VALUE as an open end. Default codec, three documents suffice. IntField.newRangeQuery, LONG sorts in the setups SortField.Type.LONG allows, and in-range bounds are not affected.

Mechanism, terms and numbers687 words

IndexSortSortedNumericDocValuesRangeQuery takes long bounds and, when the index is sorted by the queried field, binary-searches the doc-ID range of the matches instead of running its fallback query. When that sort is SortField.Type.INT (or a SortedNumericSortField with Type.INT), the doc-values binary search narrows the bound with fieldComparator.setTopValue((int) topValue), and when the field has 4-byte points (IntField) the points binary search packs IntPoint.pack((int) lowerValue) / IntPoint.pack((int) upperValue) (lines 633 and 461-462 on main 645d4fe14a20). A bound outside the int range keeps only its low 32 bits: (int) Long.MIN_VALUE is 0 and (int) Long.MAX_VALUE is -1, the documented open ends of the doc-values range queries this class wraps. The search then covers the wrong doc-ID range: [Long.MIN_VALUE, 10] over the values -5, 3 and 20 returns 1 document instead of 2, [0, Long.MAX_VALUE] returns 0 instead of 2, count() agrees with the wrong result, other bounds add documents, and on the points path some bounds throw IllegalArgumentException. The class javadoc requires the same documents as the fallback, and no documented int-range precondition was found. Every core release from 9.5.0 to 10.6.0 and main are affected; matching nothing when the range lies outside the int range and clamping the bounds otherwise fixes it.

The bug

All lines are IndexSortSortedNumericDocValuesRangeQuery.java on main 645d4fe14a20.

  1. Entry. scorerSupplier (:160-168, through getDocIdSetIteratorOrNull, :517-545) tries the points binary search first (getDocIdSetIteratorOrNullFromBkd, :430-515), then, for an INT or LONG primary sort on the field, the doc-values binary search (getDocIdSetIterator, :559-619). Only when neither applies does the fallback run. Weight#count (:177-226) uses the points search only when every document has a point (:193), and the doc-values search only when that holds or the sort's missing value lies outside the range (:214-215); otherwise it returns the fallback's count (:225). In the minimal test neither holds, and the fallback's count is -1 there (no doc-values skip index), so IndexSearcher.count counts the documents the query's scorer returns, which come from the wrong range.
  2. Doc-values path. getDocIdSetIterator binary-searches the first document with value >= lower (:569-583) and the first with value > upper (:585-602). Each probe compares against a comparator from loadComparator (:626-646), which for Type.INT does fieldComparator.setTopValue((int) topValue) (:632-633): the int comparator needs an Integer, and the cast keeps the low 32 bits of the long.
  3. Points path. For 4-byte points, :460-462 pack IntPoint.pack((int) lowerValue) and IntPoint.pack((int) upperValue) and then binary-search the point tree (:479-504). A wrapped upper end below the wrapped lower end can make the start doc greater than the end doc, and DocIdSetIterator.range then throws "minDoc must be < maxDoc" (DocIdSetIterator.java:47-50).
  4. The repro's numbers. In index-sort order the documents are -5, 3, 20. (int) Long.MIN_VALUE = 0, so the first document with value >= 0 is the one with 3, and the first with value > 10 is the one with 20: the returned range holds one document.

Which bounds

rewrite() turns [Long.MIN_VALUE, Long.MAX_VALUE] into a FieldExistsQuery (:131-133), so only one open end at a time reaches the binary search. A bound inside the int range survives the cast unchanged. A bounded-exhaustive check (14 bound values, 8 inside and 6 outside the int range, both long extremes included; values Integer.MIN_VALUE, -5, 0, 3, Integer.MAX_VALUE; forward and reverse sort; with and without a document missing the field; three missing values) against a brute-force count found, on the three supported INT-sort setups (NumericDocValuesField, SortedNumericDocValuesField, IntField), 2,382 wrong results in 7,056 checks, every one with a bound outside the int range (0 wrong of the 2,304 in-range checks). search() and count() are wrong together, or throw together (138 cases, all on the points path). Examples on the IntField path: [Long.MIN_VALUE, -2147483649] should match nothing and returns 3 documents; [Long.MIN_VALUE, -6] throws. With the fix, 0 of all 14,112 checks are wrong. (The run also covers IntField under a LONG sort, 798 more wrong results; SortField.Type.LONG requires LongPoint, so those are left out of the claim.)

Exposure and likelihood226 words
  • Who is hit. Applications that (a) sort the index by an int field, either SortField.Type.INT on a NumericDocValuesField or SortedNumericSortField with Type.INT on an IntField, and (b) construct IndexSortSortedNumericDocValuesRangeQuery themselves with a long bound outside the int range. The natural case is an open end written as Long.MIN_VALUE / Long.MAX_VALUE, the convention of the long-typed doc-values range factories this query wraps.
  • Default vs opt-in. The index sort is opt-in configuration, and the query is an expert @lucene.experimental class built by hand. Within that: default codec, no scale, assertions on or off, single thread.
  • What is wrong. Silently missed and extra documents, and a wrong count() / totalHits; on the points path some bounds throw IllegalArgumentException. Deterministic for a given index and bound.
  • Not affected. IntField.newRangeQuery (it passes int bounds), any LONG sort in a setup that SortField.Type.LONG allows (doc values only, or LongPoint / LongField), and bounds inside the int range (the cast leaves them unchanged). An IntField under a LONG sort, a setup SortField.Type.LONG rules out, does take the 4-byte points cast. The INTEGER field mappers of two major downstream search servers pass int bounds too.
  • How likely: rare. It needs the hand-built query; no matching public report was found in the recorded searches (2026-10-29, re-checked 2026-10-30); the code shipped in 9.2.0 (sandbox, the doc-values cast) and 9.5.0 (core).

Minimal repro

One JUnit method on the public API, in org.apache.lucene.search like the upstream tests: an INT index sort, three documents and the range [Long.MIN_VALUE, 10]. The fallback assertion passes, the query assertion fails. Deterministic; fails with assertions on and off.

TestIndexSortIntSortLongBound.java (lucene/core, package org.apache.lucene.search, 1 test)50 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.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndexSortIntSortLongBound extends LuceneTestCase {

  public void testOpenLowerBoundOnIntSort() throws Exception {
    IndexWriterConfig iwc = new IndexWriterConfig();
    iwc.setIndexSort(new Sort(new SortField("f", SortField.Type.INT)));
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int v : new int[] {-5, 3, 20}) {
        Document doc = new Document();
        doc.add(new NumericDocValuesField("f", v));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query fallback = NumericDocValuesField.newSlowRangeQuery("f", Long.MIN_VALUE, 10);
        Query query =
            new IndexSortSortedNumericDocValuesRangeQuery("f", Long.MIN_VALUE, 10, fallback);
        assertEquals(2, searcher.search(fallback, 10).scoreDocs.length);
        // expected 2 hits (-5 and 3) like the fallback, actual 1: -5 is missed
        assertEquals(2, searcher.search(query, 10).scoreDocs.length);
      }
    }
  }
}
Command
cp $REPRO/TestIndexSortIntSortLongBound.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestIndexSortIntSortLongBound -Ptests.seed=DEADBEEF
Expected
2 hits (-5 and 3), the same as the fallback.
Observed
1 hit: AssertionError: expected:<2> but was:<1> at the query assertion (line 46); the fallback assertion passes.

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

More files, environment, full output233 words

TestIndexSortIntSortLongBound (50 lines with the license header, one test method, public API only). The fallback assertion on line 44 passes and the query assertion on line 46 fails. As a regression test the method can go into TestIndexSortSortedNumericDocValuesRangeQuery unchanged (with its imports). :lucene:core:checkGoogleJavaFormat passes with it in the tree.

The test also fails on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars (first run with the hunt's copy of it, same code with other comments, then with this test itself). An independent reproducer's own public-API test outside the Lucene package, with the other open end ([0, Long.MAX_VALUE] over 42, -7, 1), returns 0 instead of 2 on main, 10.6.0, 10.5.2 and 9.12.3.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain Java casts, nothing platform-specific)
Command (environment)
cp $REPRO/TestIndexSortIntSortLongBound.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestIndexSortIntSortLongBound -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF
TestIndexSortIntSortLongBound > testOpenLowerBoundOnIntSort FAILED
    java.lang.AssertionError: expected:<2> but was:<1>
        at org.apache.lucene.search.TestIndexSortIntSortLongBound.testOpenLowerBoundOnIntSort(TestIndexSortIntSortLongBound.java:46)

# standalone runner, unpatched main jars
main 645d4fe14a20, -ea, seed DEADBEEF   tests_run=1 failures=1  expected:<2> but was:<1>
main 645d4fe14a20, -da, seed DEADBEEF   tests_run=1 failures=1  expected:<2> but was:<1>
main 645d4fe14a20, -da, seed 1234ABCD   tests_run=1 failures=1  expected:<2> but was:<1>

# count() probe, same data (NumericDocValuesField / IntField with SortedNumericSortField INT)
[Long.MIN_VALUE, 10]   fallback=2  search=1  totalHits=1  count=1
[0, Long.MAX_VALUE]    fallback=2  search=0  totalHits=0  count=0

Why it looks unintended

  • The javadoc lists SortField.Type.INT as supported and says the fallback "should produce the same documents": here the fallback finds 2, the query 1.
  • The constructor takes long bounds unchecked; its own rewrite() and the doc-values range factories use Long.MIN_VALUE / Long.MAX_VALUE as open ends.
  • No documented int-range precondition was found; the upstream INT-sort test only uses the Integer extremes.
The full argument281 words
  • Contracts. The class javadoc (IndexSortSortedNumericDocValuesRangeQuery.java:43-58): the optimization applies when "The sort field is of type SortField.Type.LONG or SortField.Type.INT", and "This fallback must be an equivalent range query -- it should produce the same documents and give constant scores". Its example builds the query directly from long bounds (:59-65). The constructor takes long bounds and checks nothing (:81-85); the superclass is "A Query over a range of long values" (NumericDocValuesRangeQuery.java:22). rewrite() treats Long.MIN_VALUE / Long.MAX_VALUE as open ends (:131-133) and says "This query matches the same documents as its fallback" (:136).
  • The fallback's convention. NumericDocValuesField.newSlowRangeQuery, the natural fallback for a doc-values-only int field: "You can have half-open ranges ... by setting lowerValue = Long.MIN_VALUE or upperValue = Long.MAX_VALUE" (NumericDocValuesField.java:94-95; the same in SortedNumericDocValuesField.java:90-91).
  • A legal setup. SortField.Type.INT: "Fields must either be not indexed, or indexed with IntPoint" (SortField.java:76-80); the repro's field has doc values only.
  • No precondition found. No int-range precondition was found in the javadocs, CHANGES or MIGRATE entries, tests or review threads that were read (#11502 / #749, #687, #12078, #14732).
  • History. The doc-values cast came with LUCENE-10466 (#11502 / PR #749), whose stated expectation was that the query "should not fail with class cast but correctly convert the numeric values". Its review discussed which sort types to support, not bounds; #687 (the points-path cast) did not discuss bounds either.
  • Tests. The upstream INT-sort test testSameHitsAsPointRangeQueryIntSort uses Integer.MIN_VALUE / Integer.MAX_VALUE as its open ends (TestIndexSortSortedNumericDocValuesRangeQuery.java:106-150), while the LONG tests use the long extremes as open ends (:93-95, :780-782): the case is untested, not asserted.
The devil's-advocate check160 words

The strongest case for "caller error": with an INT sort the values are ints (SortField.Type.INT: "Sort values are Integer"), IntField.newRangeQuery only ever passes int bounds, the upstream INT tests use Integer sentinels, the class is @lucene.experimental, a maintainer wrote in 2022 that it "should no longer be exposed to users" (jpountz on #749, 2022-03-30), and its deprecation was floated on #16699 (romseygeek, 2026-09-22).

Why it fails: the class is public in core, its javadoc shows users constructing it from long bounds, the constructor accepts any long, no documented int-range precondition was found, and the class's own rewrite() and its natural fallback use the long extremes as open ends. Narrow use and a possible future deprecation limit the impact, not the correctness: today the query silently returns other documents than the fallback the javadoc says it must equal. The intended-behaviour reviewer from another model family rated the argument moderate, the devil's-advocate check moderate-to-weak; both called the bug real.

Suggested fix

Recommended Match nothing outside the int range, else clamp

On the INT doc-values path and the 4-byte points path, match nothing when the whole range lies outside the int range, and otherwise clamp each bound to [Integer.MIN_VALUE, Integer.MAX_VALUE] before the cast (+17/-3, one file). The test then passes, and so do the 161 tests of the closest suites.

// 4-byte points path, and the same early return for Type.INT in getDocIdSetIterator:
if (lowerValue > Integer.MAX_VALUE || upperValue < Integer.MIN_VALUE) {
  return IteratorAndCount.empty(); // no int value can be in the range
}
queryLowerPoint = IntPoint.pack(clampToInt(lowerValue)).bytes;
queryUpperPoint = IntPoint.pack(clampToInt(upperValue)).bytes;
// loadComparator:
fieldComparator.setTopValue(clampToInt(topValue));

private static int clampToInt(long value) {
  return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value));
}
All options and trade-offs4 options ยท 634 words

One fix was validated with gradle in a scratch worktree at 645d4fe14a20 (JDK 25, -Ptests.seed=DEADBEEF); the other options were not built here.

Match nothing outside the int range, else clamp (validated by the checks) (recommended)

IndexSortSortedNumericDocValuesRangeQuery.java, +17/-3: an early IteratorAndCount.empty() on the 4-byte points path and, for Type.INT, at the top of getDocIdSetIterator; a clampToInt helper in place of the three casts.

// 4-byte points path, and the same early return for Type.INT in getDocIdSetIterator:
if (lowerValue > Integer.MAX_VALUE || upperValue < Integer.MIN_VALUE) {
  return IteratorAndCount.empty(); // no int value can be in the range
}
queryLowerPoint = IntPoint.pack(clampToInt(lowerValue)).bytes;
queryUpperPoint = IntPoint.pack(clampToInt(upperValue)).bytes;
// loadComparator:
fieldComparator.setTopValue(clampToInt(topValue));

private static int clampToInt(long value) {
  return (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value));
}

Trade-offs

  • It assumes the field's values are ints, which is what an INT sort sorts (SortField.Type.INT: "Sort values are Integer"; the sort's missing value is an Integer). For int values, clamping a bound to the int range does not change which documents match, and a range that misses the int range entirely matches none. The assumption is not enforced: NumericDocValuesField accepts any long, and the fix was not tested with stored values outside the int range under an INT sort (the setup of the separate LFV-779, where the sort compares only the low 32 bits). Ask the maintainers to confirm it, or pick the fallback option.
  • Keeps the index-sort shortcut for open-ended bounds; a few comparisons per segment (the range check once, and one clamp each time the binary search loads a comparator, O(log maxDoc) times), nothing per document.

What the checks validated

  • Gradle, :lucene:core:test with the minimal test and the closest suites: TestIndexSortIntSortLongBound 1, TestIndexSortSortedNumericDocValuesRangeQuery 29, TestIndexSorting 62, TestSortOptimization 31, document.TestField 38: 161 tests, 0 failures; :lucene:core:checkGoogleJavaFormat passes. Without the fix the same gradle run of the minimal test fails. The whole core suite was not run.
  • Earlier runs of the same diff on 645d4fe14a20: the minimal test with -ea and -da; the earliest finder's five tests; the bounded-exhaustive check, 0 wrong of 14,112. The cross-model reviewers' own tests pass on other bases: the intended-lens reviewer's with this fix on the older main ea57360912ed; the refute-lens reviewer's with its own copy of the same change on ea57360912ed, and again with this fix on 645d4fe14a20.

Clamp only

Replace the three casts with a clamp and nothing else. An independent reproducer's shadow class did exactly this and its test passed.

Trade-offs

Incomplete: a range entirely above the int range, such as [2^31, 2^32], clamps to [Integer.MAX_VALUE, Integer.MAX_VALUE] and matches the document whose value is exactly Integer.MAX_VALUE; the same at the low end. Executed with a clamp-only shadow on 645d4fe14a20, on the IntField and the doc-values path: [2^31, 2^32] returns the Integer.MAX_VALUE document, [-2^32, -2^31-1] and [Long.MIN_VALUE, -2147483649] the Integer.MIN_VALUE document, instead of none; the recommended fix returns none.

Run the fallback when a bound does not fit in an int (not built)

Return null from the two binary-search helpers for an INT sort or 4-byte points when a bound is outside the int range, so the equivalent fallback runs.

Trade-offs

Correct by the class's own contract and the smallest change in behaviour: for a bound outside the int range the result is the fallback's, whatever the stored values (no int-value assumption). But open-ended long bounds, the common case, lose the shortcut.

Deprecate the class (discussed on #16699, undecided)

romseygeek suggested on #16699 (2026-09-22) moving the index-sort shortcut into the point and doc-values range queries and deprecating this class as redundant; HoustonPutman, the PR's author, agreed and is aiming for 11.0 (the PR's milestone is 11.0.0); the PR is labelled Stale and nothing has landed.

Trade-offs

Does not fix 9.x and 10.x, where the class stays public; can be combined with the first option.

Downstream note

An application that uses such a range as a restriction filter, with a bound outside the int range, can let through documents outside the range.

How it was found

Code reading while writing a Z3 model of how the point and doc-values range queries handle their bounds: the model's obligations were satisfiable for both casts and unsatisfiable once the bounds are restricted to the int range. A second hunt found the same root through count(); a one-method JUnit and a bounded-exhaustive check confirmed it.

The method in full115 words

Method: semi-formal. The hunt's hunt on point and doc-values query equivalence (2026-10-25) raised it from code reading while writing a Z3 translation-validation model of the bound handling; the model's obligations were satisfiable for both casts and unsatisfiable once the bounds are restricted to the int range, and a bounded-exhaustive detector on the stock jar and replayed witnesses confirmed it.

  • A second hunt, on count() and totalHits shortcuts (2026-10-25), found the same root while modelling the count shortcuts with a bit-precise Z3 check; both findings are collapsed into this one.
  • An adjudication unit reduced the earliest finder's five tests to the one-method test, ran the 14,112-check bounded-exhaustive differential against a brute-force count, and wrote the fix.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-29, re-checked 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, three downstream projects or the web. Related: #749 and #687 introduced the casts; open #16699 keeps them and floated deprecating the class.

Searches4 searches

Related, not reports of this bug: #11502 / #749 (LUCENE-10466, introduced the doc-values cast while fixing a ClassCastException), #687 (LUCENE-10425, the points-path cast), #14732 / #14736 (INT-sort missing-value fix, other code), #16884 / #16886 / #16893 (multi-valued fields and document blocks, same class, other causes), #736 (LUCENE-10458, missing-value count), open #16698 / #16699 (float/double support; its diff keeps both casts), open draft #13886 (drops only the points-path cast, as a side effect), open #13973 (sort validation). Limits: GitHub search does not index diff code (the open PRs that touch the file were checked by their patches); private reports to the security list are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@, commits@, JIRA archive, web)2026-10-29two runs of 51 queries: the class name, setTopValue, IntPoint.pack, cast, narrowing, clamp, Long.MAX_VALUE / Integer.MAX_VALUE, wrong results211 and 1,418 hits, none about this bug
GitHub search API (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr)2026-10-2926 queries: the class name (all, open, recently updated), cast fragments, "minDoc must be < maxDoc", overflow and out-of-range phrasingsno report
Open PRs that change the file, and the file's history2026-10-29patches of #16699, #13886, #13973, #16510; commits on main, branch_10x and branch_10_6none guards or clamps the bounds
GitHub, re-check2026-10-30apache/lucene items naming the class updated since 2026-10-29; upstream main and branch_10_6 file contentsonly #16810 (unrelated); the casts are still there

History

Introduced by LUCENE-10466 (#749, 2022; the doc-values cast, in the sandbox module from 9.2.0) and LUCENE-10425 (#687, 2022; the points-path cast, first released in 9.5.0); in core since 9.5.0 (#12078). Fails on 9.5.0, 9.12.3, 10.5.2, 10.6.0 and main; sandbox 9.1.0 threw ClassCastException instead, so no release ever handled these bounds.

Versions tested7 rows

Doc-values cast: 737ce42c1c0 on main (03dd543572c on branch_9x), LUCENE-10466 (#11502, PR #749 by reta, 2022-04-04), "Ensure IndexSortSortedNumericDocValuesRangeQuery handles sort types besides LONG": it replaced setTopValue(topValue), which threw ClassCastException on INT sorts, with setTopValue((int) topValue). Points-path cast: 5b24a233bdf (f31292c1441 on branch_9x), LUCENE-10425 (#687, 2022-09-22); git blame now shows 8671e29929c (#12003), which only moved the lines. Into core: #12078 (102622842bf, 2023-01-13), so 9.5.0 is the first core release. Upstream main and branch_10_6 still have all three casts (2026-10-30); the last commit to the file is ffa9d816eeb on main and its backport afeeeff2d14 on branch_10_6 (#16893).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da)
10.6.0 affectedfails (release jar, -ea and -da)
10.5.2, 9.12.3 affectedfail (-ea and -da)
9.5.0 first core releasefails (-da)
sandbox 9.2.0, 9.4.2 sandboxthe sandbox class fails the same test (-da)
sandbox 9.1.0 fails loudlyClassCastException in IntComparator.setTopValue instead (the LUCENE-10466 bug)
the fix (scratch worktree at 645d4fe14a20) not affectedthe test and 161 tests of the closest suites pass

Independent checks

An independent reproducer, reviewers from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication each ran their own or the finder's tests; all call it real and agree on the root cause and the fix.

Checks and verdicts254 words

The devil's-advocate check asked to drop two reach claims that rest on setups SortField documents as unsupported (LongField under an INT sort, IntField under a LONG sort); this doc does not make them.

Each independent check and its verdict
CheckVerdictWhat it found
finder (judge unit) candidateReduced the earliest finder's repro to the one-method test (fails -ea and -da, 5 seeds); the 14,112-check differential, 3,180 wrong on stock and 0 with the fix; the fix.
cross-model review, intended lens real, highOpenAI GPT-5.6. Own tests on the doc-values and IntField paths: 0 instead of 2; controls pass; the intended-behaviour argument moderate and rejected.
Independent reproducer realOwn public-API test outside the Lucene package, [0, Long.MAX_VALUE]: 0 instead of 2 on main, 10.6.0, 10.5.2 and 9.12.3 (-ea, -da); a shadow that only clamps the casts passes.
Cross-model review, refute lens real, highOpenAI GPT-5.6. Own 4-test suite: 0 instead of 3 on both paths over several seeds, also without the randomized test directory; controls pass; the fix passes.
Intended-behaviour devil's advocate realStrongest counter-case moderate-to-weak: no documented int-range precondition was found.
adjudication confirmed, highRe-ran the finder's test (-ea, -da) and the independent reproduction's (-da) on main: both fail. Not noise; not security-sensitive.
this doc gradle repro and fixRan the minimal test with gradle on a clean 645d4fe14a20 (fails) and validated the recommended fix with gradle in a scratch worktree; re-checked upstream on 2026-10-30.

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

Finding record

LFV-1049, snapshot of 2026-11-06

Registry title
IndexSortSortedNumericDocValuesRangeQuery casts its long bounds to int on an INT index sort, so Long.MIN_VALUE / Long.MAX_VALUE open ends (or any bound outside the int range) silently return wrong hits and counts
Area
search / IndexSortSortedNumericDocValuesRangeQuery (index-sort range shortcut: INT sort comparator path and 4-byte points path)
Priority
Medium, rank 83: Both give deterministic silent wrong hits and counts in a rare index-sort setup, and the natural Long.MIN_VALUE/Long.MAX_VALUE open-end idiom makes this more than the type mistake behind LFV-779, but it needs the expert query built by hand.
Impact class
silent wrong results
Impact note
Hits applications that sort the index by an int field (SortField.Type.INT, or SortedNumericSortField with Type.INT over an IntField) and build IndexSortSortedNumericDocValuesRangeQuery directly with a bound outside the int range, most naturally Long.MIN_VALUE or Long.MAX_VALUE as an open end: search() and count() silently miss or add documents (on the 4-byte points path some bounds throw IllegalArgumentException 'minDoc must be < maxDoc' instead); default codec, no scale, -ea or -da; IntField.newRangeQuery passes int bounds and is not affected, nor are LONG sorts or bounds inside the int range.
Summary
You sort the index by SortField("f", SortField.Type.INT), index the values -5, 3 and 20 in a NumericDocValuesField, and search new IndexSortSortedNumericDocValuesRangeQuery("f", Long.MIN_VALUE, 10, fallback), where the fallback is NumericDocValuesField.newSlowRangeQuery("f", Long.MIN_VALUE, 10) (f <= 10, written with the open-end convention that the fallback documents). You expect the 2 documents the fallback returns, as the class javadoc requires. You get 1: the doc-values binary search narrows the bound with (int) Long.MIN_VALUE, which is 0, so -5 is missed; (int) Long.MAX_VALUE is -1 in the same way, and the 4-byte points path has the same cast.
Introduced
737ce42c1c0a8697009b97d72386f2c165d07add on main (branch_9x 03dd543572c), LUCENE-10466 (#11502, PR #749 by reta): replaced a ClassCastException on INT sorts with the unguarded fieldComparator.setTopValue((int) topValue) in the then-sandbox class [read; release boundary exec: sandbox 9.1.0 throws ClassCastException, sandbox 9.2.0 fails the repro]. The points-path cast IntPoint.pack((int) lowerValue / (int) upperValue) came with 5b24a233bdf (branch_9x f31292c1441), LUCENE-10425 (PR #687) [read]; git blame shows 8671e29929c (#12003), which only moved the lines. #12078 (102622842bf, branch_9x f70530e5840) moved the class into core (2022-04-04 (author date of 737ce42c1c0; points-path cast 2022-09-22; move to core 2023-01-13))
First affected version
9.5.0 [exec] (first core release, after #12078 moved the class out of sandbox); the same code was in the sandbox module from 9.2.0 [exec; sandbox is out of hunt scope]
Affected versions
[exec] main 645d4fe14a20 (-ea and -da: finder, the independent reproduction, the cross-model review check, the intended-behaviour check, judge and the re-run), 10.6.0 (Maven Central core jar, commit 4ae946e5a9b; the independent reproduction -ea and -da, follow-up -da), 10.5.2 and 9.12.3 (the independent reproduction -ea and -da, follow-up -da), 9.5.0 (-da); sandbox 9.2.0 and 9.4.2 fail (-da), sandbox 9.1.0 throws ClassCastException instead (the LUCENE-10466 bug), so no release ever handled these bounds correctly. [read] every core release from 9.5.0 to 10.6.0; upstream main (casts at lines 461, 462 and 633 via the contents API, 2026-10-29; last change to the file ffa9d816eeb, author date 2026-10-26, already in 645d4fe), branch_11x (same last commit) and branch_10_6 7e5aec4370e. Released in 10.6.0: tag releases/lucene/10.6.0 exists (tag object e29d290eeb05, peels to 4ae946e5a9b; git ls-remote 2026-10-29). Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by Z3, exhaustive check
Upstream and prior reports
none found (2026-10-29, read-only). The prior-report record (verdict new): two scripted prior-report searches (51 queries each over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 211 and 1,418 hits, 0 with high overlap), 26 manual GitHub search-API queries (apache/lucene incl. items created or updated since 2026-10-27, Elasticsearch, OpenSearch, Solr), the patches of #16699, #13886, #13973, #16510, #16810, #16947 and #16917, the file's commit history on main, branch_10x and branch_10_6, the 10.6.0 tag contents and the dev@ thread; plus the candidate's 51-query pass, the independent reproduction's and the intended-behaviour check's searches and the refresh (items naming the class created or updated since 2026-10-28: only #16810, unrelated; PRs created on 2026-10-29, #16938-#16949, touch other files; the casts are still on upstream main). Related, not reports: #11502 / #749 (LUCENE-10466, introduced the doc-values cast while fixing a ClassCastException), #687 (LUCENE-10425, points-path cast), #14732 (INT-sort missing-value fix, different code), #16884 / #16886 / #16893 (multi-valued count and document blocks, same class, different causes), #736 (LUCENE-10458, missing-value count), open #16698 / #16699 (float/double support; its diff keeps both casts; a maintainer suggested deprecating the class, not decided), open #13886 (draft; drops only the points-path cast incidentally), open #13973 (sort validation), elastic/elasticsearch#162656 (nested documents)
Lucene commit tested
main 645d4fe14a20: the one-method test through gradle (fails; with the fix it passes together with TestIndexSortSortedNumericDocValuesRangeQuery, TestIndexSorting, TestSortOptimization and document.TestField, 161 tests, and checkGoogleJavaFormat) and through the standalone runner (-ea, -da, two seeds); the test on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars (and, earlier, the hunt's copy of it with other comments on those jars and the sandbox class of 9.4.2, 9.2.0 and 9.1.0); an independent reproducer's own test on 10.6.0, 10.5.2 and 9.12.3 (-ea, -da); a 14,112-check bounded-exhaustive differential on main, stock and fixed.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:43-58 (at 645d4fe14a20) (Type.INT listed as supported; 'This fallback must be an equivalent range query -- it should produce the same documents'), :67 (@lucene.experimental), :131-136 (rewrite(): Long.MIN_VALUE / Long.MAX_VALUE as open ends; 'matches the same documents as its fallback'), :461-462 (IntPoint.pack((int) lowerValue / (int) upperValue) for 4-byte points), :632-633 (fieldComparator.setTopValue((int) topValue) for Type.INT)
  • lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:91-95 (at 645d4fe14a20) and SortedNumericDocValuesField.java:87-91 (newSlowRangeQuery: bounds included; half-open ranges by Long.MIN_VALUE / Long.MAX_VALUE)
  • lucene/core/src/java/org/apache/lucene/document/IntField.java:141 (at 645d4fe14a20) and LongField.java:144 (the in-tree factories that build this query; IntField passes int bounds)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:43-58 (javadoc: Type.INT supported; equivalent fallback), :81-85 (long bounds, no check), :131-136 (rewrite open ends; 'matches the same documents as its fallback'), :160-226 (scorerSupplier, count), :430-515 (points path; :460-462 IntPoint.pack((int) ...)), :517-545, :559-619 (doc-values binary search), :626-646 (loadComparator; :632-633 setTopValue((int) topValue))
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:47-50 (minDoc must be < maxDoc)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SortField.java:76-92 (Type.INT: 'not indexed, or indexed with IntPoint'; Type.LONG: LongPoint)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:193, :214-215, :225 (Weight#count conditions); document/SortedNumericDocValuesRangeQuery.java:168-195 (fallback count, -1 without a skip index); search/TotalHitCountCollector.java:47-53 (counts the scorer's documents when count is -1)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/IntField.java:135-143 (newRangeQuery passes int bounds)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:67 (@lucene.experimental)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:43-67, :81-85, :131-136
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/NumericDocValuesRangeQuery.java:22; document/NumericDocValuesField.java:90-98; document/SortedNumericDocValuesField.java:86-94; search/SortField.java:76-80
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestIndexSortSortedNumericDocValuesRangeQuery.java:93-95, :106-150, :780-782
  • github.com/apache/lucene/issues/11502 (issue body: 'should not fail with class cast but correctly convert the numeric values', read 2026-10-30); github.com/apache/lucene/pull/749 (jpountz comment 2022-03-30); github.com/apache/lucene/pull/16699 (romseygeek and HoustonPutman comments 2026-09-22)
Suggested fix
  • github.com/apache/lucene/pull/16699 (comments of 2026-09-22; open, labelled Stale)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SortField.java:76-80, :494; search/SortedNumericSortField.java:272 (the INT missing value is cast to Integer); search/comparators/IntComparator.java:81; IndexSortSortedNumericDocValuesRangeQuery.java:570-602 (comparator reloads)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSortSortedNumericDocValuesRangeQuery.java:632-633

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

Repro files

About the diagrams

A small diagram, made in parallel with this doc from the executed runs.

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.