All findings · Lucene finding · LFV-141 · slow-range-dv-query-equals-uses-caller-arrays

Doc-values range queries compare equal on the caller's arrays, so queries built from reused arrays are merged although they match different ranges

A query from IntRangeDocValuesField.newSlowIntersectsQuery (or its Long, Float and Double versions) matches the range it copied at construction, but compares equal to whatever the caller's arrays hold now. Build [0,3] OR [5,6] through one reused array pair, and a document with range [5,10] gets 0 hits instead of 1.

Summary

Priority
Low, rank 868Queries from the *RangeDocValuesField.newSlowIntersectsQuery factories compare equal on the caller's mutable arrays, so reusing those arrays makes BooleanQuery silently drop or merge clauses, but it needs a rarely used factory with no known caller plus array reuse.
Impact
silent wrong results
Since
8.2.0 (LUCENE-8362, 2019)introduced 2019-06-10 · about 7 years
Affected
8.2.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A doc-values range query holds its range twice: an encoded copy that it matches against, and the caller's own arrays, which it uses for equality and printing. Change the arrays and the two disagree.

Doc-values range queries and query equality

  1. IntRangeDocValuesField stores a range such as [5,10] in doc values. IntRangeDocValuesField.newSlowIntersectsQuery(field, min, max) finds documents whose range overlaps [min, max] by scanning those doc values; Long, Float and Double versions exist too.
  2. Lucene compares queries with equals and hashCode wherever it de-duplicates or caches them: BooleanQuery merges equal clauses when it rewrites, and a query cache looks its entries up by query.
  3. So two queries should be equal only if they match the same documents: Query#equals is "required so that QueryCache works properly".
Diagram of the bug: one document with range [5,10]. Query 1 is built from the arrays min = {0}, max = {3} and encodes [0,3] for matching; the caller then writes 5 and 6 into the same arrays and builds query 2, which encodes [5,6]. Both queries keep a reference to the same arrays, so both compare equal as [5,6]. BooleanQuery merges the two SHOULD clauses and keeps query 1, which matches [0,3]: 0 hits, expected 1.
Two queries built from one pair of arrays: each matches its own range, [0,3] and [5,6], but both read the shared arrays for equality, so BooleanQuery keeps one and the document with range [5,10] is missed. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index one document with an IntRangeDocValuesField range of [5,10].
  2. You build newSlowIntersectsQuery("r", min, max) with min = {0}, max = {3}, then write 5 and 6 into the same two arrays and build a second query. Both go into a BooleanQuery as SHOULD clauses: [0,3] OR [5,6].
  3. You expect 1 hit, because [5,6] overlaps [5,10]. The points factory IntRange.newIntersectsQuery gives 1 on the same loop.
  4. You get 0. Each query matches the range it encoded when it was built, but equals and hashCode read the caller's arrays, which now hold [5,6] for both. BooleanQuery merges the two "equal" clauses and keeps the first, [0,3].
  5. No error. The same aliasing drops one of two such FILTER or MUST_NOT clauses, lets a query cache serve one query's hits for the other, and makes toString print [5 TO 6] for the [0,3] query.

Who is affected

Applications that call a newSlowIntersectsQuery factory, then write new bounds into the same arrays to build another query on that field, typically a loop over windows through one scratch pair. Default configuration; the BooleanQuery effects need no cache. Rare: only a Lucene test calls these factories; code search found none in Elasticsearch, OpenSearch or Solr.

Mechanism, terms and numbers651 words

Int/Long/Float/DoubleRangeDocValuesField.newSlowIntersectsQuery(field, min, max) returns a *RangeSlowRangeQuery that encodes min and max into a fresh byte array for matching, but keeps the caller's two arrays by reference and bases equals, hashCode and toString on them, overriding its parent class's correct equality over the encoded bytes. When the caller writes new bounds into the same arrays to build another query on the same field, as a loop over several windows through one scratch pair does, the first query keeps matching its old range but compares equal to, hashes like and prints the new one. BooleanQuery then merges or drops the "duplicate" clauses ([0,3] OR [5,6] gives 0 hits instead of 1 on a document with range [5,10]; a FILTER or a MUST_NOT window is lost; a FILTER plus MUST_NOT pair matches nothing), and a query cache can serve one query's hits for the other. No query cache is needed for the BooleanQuery effects. Affected: every release since the API appeared in 8.2.0, through 10.6.0, and main. The fix clones the two arrays in the four constructors.

The bug (main 645d4fe14a20)

  1. The factory passes the caller's arrays through. IntRangeDocValuesField.newSlowIntersectsQuery returns new IntRangeSlowRangeQuery(field, min, max, queryType) (IntRangeDocValuesField.java:58-72; Long, Float and Double the same). Its javadoc says only that the query "doesn't leverage indexing and may be slow" (:64-69).
  2. Matching uses a copy made at construction. The constructor calls super(field, encodeRanges(min, max), ...) (IntRangeSlowRangeQuery.java:32), and encodeRanges writes into a new byte[] (:84-89). The parent BinaryRangeFieldRangeQuery stores it as queryPackedValue (:46-53) and matches against it (:123-129).
  3. Identity uses the caller's live arrays. The constructor keeps this.min = min; this.max = max; (IntRangeSlowRangeQuery.java:34-35), and equals (:38-47), hashCode (:49-56) and toString (:65-77) read only those arrays. Long, Float and Double: :36-37, :40-49, :51-58, :67-79.
  4. The parent's correct equality is overridden. BinaryRangeFieldRangeQuery.equals/hashCode compare the field and queryPackedValue (BinaryRangeFieldRangeQuery.java:66-82), exactly what matching uses; the four subclasses override both and never call super.
  5. BooleanQuery de-duplicates on equality. Its constructor puts SHOULD and MUST clauses into Multisets and FILTER and MUST_NOT clauses into HashSets (BooleanQuery.java:129-141). A Multiset is a HashMap from query to count (Multiset.java:33, add at :80-84), so adding q2 finds the equal key q1, keeps it and counts it twice; a HashSet keeps only the first-added of two equal clauses. rewrite then merges the repeated SHOULD entries into one clause (:442-447, deduplicateClauses :714-758), drops duplicate FILTER and MUST_NOT clauses (:343-361), and returns MatchNoDocsQuery("FILTER clause also in MUST_NOT") when a FILTER equals a MUST_NOT clause (:365-369).
  6. A query cache keys on the query, so an entry filled by one query is found for the other. Main installs no cache by default (IndexSearcher.java:82-84); 9.x and 10.x up to 10.6.0 have a static default LRUQueryCache that every IndexSearcher starts with unless the application replaces it.

The numbers

One document with range [5,10]; min = {0}, max = {3}, build q1; min[0] = 5; max[0] = 6, build q2. q1 still matches [0,3] (0 hits) and q2 matches [5,6] (1 hit), yet q1.equals(q2) is true, the hash codes are equal and q1.toString() prints r:[[5] TO [6]]. Query cache off:

  • q1 OR q2: 0 hits, expected 1 (the minimal repro);
  • FILTER [5,6] AND FILTER [0,3]: 1 hit, expected 0 (with the clauses in the other order the answer happens to be right);
  • FILTER [5,6] AND MUST_NOT [20,30]: 0 hits, expected 1;
  • two MUST_NOT windows [0,3] and [5,6] next to MatchAllDocsQuery over four documents: 3 hits, expected 2.

With an always-caching LRUQueryCache, counting q1 and then q2 gives q2 0 hits, expected 1. All four types (Int, Long, Float, Double) fail the same way; the same tests with fresh arrays pass.

Exposure and likelihood321 words
  • What goes wrong. Silent wrong hit counts in both directions: a disjunction loses a window, a FILTER restriction or a MUST_NOT window is dropped (over-match), or a conjunction becomes "match nothing" (under-match). No error is raised.
  • Configuration. An otherwise-default IndexWriterConfig with the default codec (the test pins it with TestUtil.getDefaultCodec()) and a plain IndexSearcher. The BooleanQuery effects need no query cache. Scale does not matter: the repro uses one document.
  • What it takes. The caller writes into the min/max arrays after passing them to a factory and builds another query of the same type on the same field (equals compares the field, IntRangeSlowRangeQuery.java:44). Building each query from fresh arrays is safe.
  • Through a query cache. On 9.x and 10.x the static default LRUQueryCache, on main a cache the application installs. Three more things must line up: the wrong entry is cached first; a later query of the same type, on the same field, has exactly the bounds the shared arrays hold; and the segment and query pass the default cache's checks (among them a segment of at least 10,000 documents and more than half the average segment size, and, for this kind of query, 5 uses). With the default 9.12.3 and 10.5.2 cache and no configuration, on one 12,000-document segment, a scratch-built query that filled the cache made a later, correctly built query with exactly those bounds on another IndexSearcher return 10,010 hits instead of 20. The extra documents are live documents of the same segment that match the first query's range; deleted documents and other segments are not involved.
  • Misleading output. toString, used in logs, explanations and profiles, prints the caller's current arrays, not the range the query matches.
  • How likely. Rare. Inside Lucene only TestRangeFieldsDocValuesQuery calls these factories, always with fresh arrays, and read-only code search found no call in Elasticsearch, OpenSearch or Solr (code search does not cover private code).

Minimal repro

One JUnit class, one method, public API only: one document with range [5,10], [0,3] OR [5,6] built through one reused min/max pair, the query cache off, and an assertion of 1 hit. The data and the queries are deterministic; on main it fails with assertions on and off.

TestSlowRangeQueryReusedArrays.java (one test class)55 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 java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.IntRangeDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestSlowRangeQueryReusedArrays extends LuceneTestCase {

  public void testDisjunctionBuiltFromReusedArrays() throws IOException {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      Document doc = new Document();
      doc.add(new IntRangeDocValuesField("r", new int[] {5}, new int[] {10}));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        int[] min = {0};
        int[] max = {3};
        BooleanQuery.Builder b = new BooleanQuery.Builder();
        b.add(IntRangeDocValuesField.newSlowIntersectsQuery("r", min, max), Occur.SHOULD);
        min[0] = 5;
        max[0] = 6;
        b.add(IntRangeDocValuesField.newSlowIntersectsQuery("r", min, max), Occur.SHOULD);
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(null);
        // expected: [0,3] OR [5,6] matches the one doc with range [5,10] (count 1); actual: 0
        assertEquals(1, searcher.count(b.build()));
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSlowRangeQueryReusedArrays -Ptests.seed=DEADBEEF
Expected
[0,3] OR [5,6] overlaps the document's range [5,10], so the count is 1.
Observed
expected:<1> but was:<0>: the two clauses compare equal, and only the first, [0,3], is kept.

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

More files, environment, full output191 words

The class sits in org.apache.lucene.search, next to TestRangeFieldsDocValuesQuery, and uses nothing package-private. It pins the default codec, uses ByteBuffersDirectory, a plain IndexWriter and an IndexSearcher with the query cache turned off, so the wrong count comes from BooleanQuery de-duplication alone. It compiles on 9.1.0 and later (the org.apache.lucene.tests.* test-framework packages).

Regression test: this same test, as written. With the fix it passes, together with the core format, ECJ and forbidden-apis checks.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 (assertions on): fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java logic)
Command (environment)
cp $REPRO/TestSlowRangeQueryReusedArrays.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSlowRangeQueryReusedArrays -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestSlowRangeQueryReusedArrays > testDisjunctionBuiltFromReusedArrays FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:19BC7A99D7656228]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.search.TestSlowRangeQueryReusedArrays.testDisjunctionBuiltFromReusedArrays(TestSlowRangeQueryReusedArrays.java:51)

# standalone runner, seed DEADBEEF: the same message, 1 of 1 failed each
main 645d4fe14a20 (-ea, -da); 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 (-ea)

Why it looks unintended

  • Query#equals exists "so that QueryCache works properly": equal queries should filter the same documents, but these match 0 and 1.
  • The query disagrees with itself: it matches a copy taken at construction, which its parent class already compares.
  • Sibling factories copy their input (IntRange.newIntersectsQuery, NumericDocValuesField.newSlowSetQuery); nothing documents that these arrays must not be reused.
The full argument314 words
  • The equality contract. Query#equals "is required so that QueryCache works properly", and "Typically a query will be equal to another only if it's an instance of the same class and its document-filtering properties are identical to those of the other instance" (Query.java:95-107). After reuse, two queries that match 0 and 1 documents are equal.
  • Internal inconsistency. The query already snapshots its bounds for matching, and BinaryRangeFieldRangeQuery already compares that snapshot (BinaryRangeFieldRangeQuery.java:66-82); only the four subclass overrides read the live arrays. That holds whether or not reusing arrays is good practice.
  • Siblings and documentation. IntRange.newIntersectsQuery encodes into fresh bytes (IntRange.java:202-204, :254); NumericDocValuesField.newSlowSetQuery and SortedNumericDocValuesField.newSlowSetQuery clone their values (NumericDocValuesField.java:127-128, SortedNumericDocValuesField.java:125-126); the public Term(String, BytesRef) constructor copies its bytes (Term.java:50-54). Some APIs that keep a caller's array say so: MultiPhraseQuery.Builder.add(Term[]), "The array is not copied or mutated, the caller should consider it immutable subsequent to calling this method" (MultiPhraseQuery.java:109-113). Others keep it without saying so: KnnFloatVectorQuery and KnnByteVectorQuery store the caller's target array (KnnFloatVectorQuery.java:97, KnnByteVectorQuery.java:96), but they match and compare on that same array, so they stay consistent with themselves. The four factories document nothing, and their queries are not consistent with themselves.
  • History. The classes came with LUCENE-8362 (#9409, 2019). Its review asked only for Arrays.equals/Arrays.hashCode instead of identity on the arrays; nobody discussed reuse or copying. TestRangeFieldsDocValuesQuery always passes fresh arrays and never checks equals.
  • Precedent. #12158 (merged 2023) fixed a factory that passed the caller's array into a query by cloning it in KeywordField#newSetQuery. LUCENE-6369 (#7429, 2015): "It is very important for the query cache that queries be either immutable or clone deeply so that they cannot change after having been put into the cache"; it was closed Won't Fix in 2015 as "Superseded by #7648: queries should now be immutable" (LUCENE-6590).
The devil's-advocate check296 words

The strongest case for "intended" (cross-model reviews, a devil's-advocate verifier and the adjudication built it): Java APIs do not promise defensive copies; KnnFloatVectorQuery also keeps the caller's target array (KnnFloatVectorQuery.java:97); in LUCENE-6321 (#7382, 2015) Robert Muir called modifying term bytes after building a query "an abuse case, and its gonna be problematic everywhere in lucene apis if you do this"; and IndexSearcher.setQueryCache says "When using a query cache, queries should not be modified after they have been passed to IndexSearcher" (IndexSearcher.java:268-269).

Why it fails: the caller never calls anything on a query after building it, the arrays change before either query reaches a searcher, and the BooleanQuery results are wrong with no cache, so the setQueryCache note does not apply. Still, because q1 keeps the arrays, a maintainer can fairly call the write an indirect change to q1; the next point does not depend on how that write is read. In the Knn and Term cases one input drives both matching and equality, so those queries stay self-consistent; here matching is snapshotted and equality is not. LUCENE-6321 (#7382) was closed Won't Fix "in favor of #7429" (LUCENE-6369), whose goal is immutable or deeply cloned queries; #7429 was in turn closed Won't Fix as "Superseded by #7648: queries should now be immutable". Through the arrays they keep, these four queries are not immutable; the fix makes them so. The earlier cross-model reviews (two separate OpenAI GPT-5.6 runs with different lenses) rated the works-as-designed case weak, a later cross-model fact check of this doc rated it moderate for the indirect-change reading above, and the independent reproducer, the devil's-advocate verifier and the adjudication rejected it. The reach is narrow, so "low priority" is a fair maintainer reply; "not a bug" is much harder to justify.

Suggested fix

Recommended Clone min and max in the four constructors (recommended, validated)

Clone min and max in the four *RangeSlowRangeQuery constructors (+8/-8 lines), so equality, hashing and printing use the same snapshot as matching. Validated: the repro passes with assertions on and off, 576 core document and related search tests pass, and so do the core format, ECJ and forbidden-apis checks.

IntRangeSlowRangeQuery(String field, int[] min, int[] max, RangeFieldQuery.QueryType queryType) {
  super(field, encodeRanges(min, max), IntRange.BYTES, min.length, queryType);
  this.field = field;
  this.min = min.clone(); // was: this.min = min;
  this.max = max.clone(); // was: this.max = max;
}
All options and trade-offs3 options ยท 379 words

Two fixes were validated through gradle in a scratch worktree of 645d4fe14a20; the recommended one is the complete one. A documentation-only change was not built.

Clone min and max in the four constructors (recommended, validated)

In IntRangeSlowRangeQuery, LongRangeSlowRangeQuery, FloatRangeSlowRangeQuery and DoubleRangeSlowRangeQuery, store copies of the caller's arrays. Then equals, hashCode and toString all describe the range the query matches.

IntRangeSlowRangeQuery(String field, int[] min, int[] max, RangeFieldQuery.QueryType queryType) {
  super(field, encodeRanges(min, max), IntRange.BYTES, min.length, queryType);
  this.field = field;
  this.min = min.clone(); // was: this.min = min;
  this.max = max.clone(); // was: this.max = max;
}

Trade-offs

  • Cost. Two small array copies (one value per dimension) per query construction; matching is unchanged.
  • Placement. The constructors are package-private and only the four factories call them (*RangeDocValuesField.java:61), so cloning in the factories, as #12158 did for KeywordField#newSetQuery, would be equivalent today; the constructor also covers any future caller.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: the repro and TestRangeFieldsDocValuesQuery pass with assertions on and off (7 tests each way).
  • :lucene:core:test over org.apache.lucene.document.* plus the repro, TestRangeFieldsDocValuesQuery, TestBooleanRewrites and TestLRUQueryCache: 63 classes, 576 tests, 29 skipped, 0 failures.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest (with the repro test in place), forbiddenApisMain, forbiddenApisTest: pass.
  • Earlier, on ea57360912ed with the fix shadowed: the finder's, the independent reproducer's, the devil's-advocate verifier's, the cross-model reviewers' and the security probe's tests all pass.

Drop the subclass equals/hashCode overrides (validated, partial)

Delete the four equals/hashCode overrides (and the then-unused java.util.Objects import), so BinaryRangeFieldRangeQuery's equality over the encoded bytes applies (-84 lines).

Trade-offs

toString still prints the caller's live arrays, so a query can still print a range it does not match. It can be combined with the clone fix, but the clone fix alone is enough.

What the checks validated

Gradle on 645d4fe14a20: the repro and TestRangeFieldsDocValuesQuery pass (7 tests); checkGoogleJavaFormat and ecjLintMain pass.

Document that the arrays must not be modified (not recommended, not built)

Add a javadoc note to the four factories.

Trade-offs

Makes the current behaviour official but leaves the query internally inconsistent, and keeps the silent BooleanQuery and cache effects for anyone who misses the note.

Downstream note

Code that builds MUST_NOT deny windows or FILTER restrictions through reused arrays can silently return documents a restriction should hide; no project doing this is known, and the fix removes the effect.

How it was found

Code reading. While writing a bounded-exhaustive test of the doc-values range queries, which builds fresh arrays for every query and so cannot see this, the finder compared each query's equals and hashCode with the state its matching uses, then confirmed the mismatch with a public-API JUnit.

The method in full130 words

Method: non-formal (code reading, then public-API JUnit tests).

  • A hunt unit on document field factories was writing a bounded-exhaustive differential test of the doc-values range-query path. That harness builds fresh arrays for every query, so it cannot see this defect.
  • While writing it, the finder read the *RangeSlowRangeQuery classes and saw that equals/hashCode read the caller's arrays while matching reads an encoded copy. A JUnit on the stock main jars confirmed it (3 of 3 tests fail with assertions on and off, and on 10.5.2 and 9.12.3).
  • An independent reproducer, a devil's-advocate verifier (who added the scratch-array OR loop), cross-model reviews (OpenAI GPT-5.6) and an adjudication checked it; a follow-up ran it from 8.1.1 to main and probed the cache path; the repro was minimized to one test.

Prior reports, history and checks

Prior reports

No prior report found in the recorded searches (2026-10-26 over GitHub, commits, the mailing lists, JIRA, downstream projects and the web; GitHub rechecked on 2026-10-31). Closest: a 2023 clone-in-factory fix for KeywordField#newSetQuery and the 2015 rule that cached queries be immutable.

Searches2 searches

Verdict new: no issue, PR, commit, list thread, JIRA entry or downstream report describes these queries comparing the caller's arrays. Related, not reports: #9409 (LUCENE-8362, origin; its review discussed only identity versus content comparison of the arrays), #7429 / #7382 / #7648 (LUCENE-6369 / 6321 / 6590, the 2015 policy that queries be immutable or clone mutable input for the query cache; none names these classes), #12158 (a merged clone fix in KeywordField#newSetQuery, a precedent), #8361, #10322, #11950, #16657. Open PRs #16847, #14475 and #13948 touch the parent class or BinaryRangeDocValues, not the four subclasses, and would not fix it. Upstream main 2f424fd428da (2026-10-30) still has the code; the four files last changed in 2022.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, JIRA, web2026-10-26a scripted prior-report search (51 queries), 19 manual GitHub searches, the changed files of all 413 open PRs, 14 issues and PRs read with all comments (all 35 comments of #9409), direct list-archive and JIRA searches, plus the finder's and a verifier's searchesno report; related #9409, #7429, #7382, #7648, #12158, #8361, #10322, #11950, #16657
GitHub (recheck)2026-10-31newSlowIntersectsQuery, RangeSlowRangeQuery, "RangeDocValuesField equals", "newSlowIntersectsQuery clone" in apache/lucene issues and PRs; the four files' commit history on upstream main0 hits; the files last changed in 6cde41c9fd8 (2022)

History

Introduced with the API by LUCENE-8362 (f84afab0083, backport d2ff7ffafcd, 2019-06-10), first released in 8.2.0. Every release from 8.2.0 to 10.6.0 and main is affected; 8.1.1 has no such API. Not fixed.

Versions tested6 rows

Introduced by LUCENE-8362, "Introduce DocValues Fields and Range Queries for native Range Field Types" (author atris, committed by jpountz on 2019-06-10): f84afab0083 on master (later 9.0) and the branch_8x backport d2ff7ffafcd (8.2.0). It came as JIRA patches (now #9409), with no GitHub PR. git blame credits the this.min = min; this.max = max; lines and the equals/hashCode bodies to f84afab0083; the only later commits to the four files are the LUCENE-9570 reformat (2d6ad2fee6d) and #11840 (6cde41c9fd8), which touch neither. No commit-level bisect is possible, because the parent commits have no such API.

Releases not executed are inferred: the four classes are in every SHA-1-verified lucene-core jar from 8.2.0 to 10.5.2, and git blame shows the aliasing constructor lines and equals/hashCode unchanged since f84afab0083; the only later commits are the reformat and the rewrite-signature change. Upstream main 2f424fd428da (2026-10-30) is unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (0 hits, expected 1)
10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedminimal repro fails, same message (assertions on)
8.2.0 firstversion-portable probe: defect in all four types, fresh-array controls correct; an 8.x-adapted JUnit fails 3 of 3
8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, main ea57360912ed affectedversion-portable probe: defect in all four types, controls correct
8.1.1 not affectedthe API does not exist; the repro does not compile
with the clone fix (645d4fe14a20) not affectedrepro passes; 576 core document and related search tests pass

Independent checks

An independent reproducer, a devil's-advocate verifier and cross-model reviews all said real; the adjudication confirmed with high confidence. The reproducer's and a review's fresh-array controls passed, and the clone fix made every check's test pass.

Checks and verdicts358 words

Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with identical files involved) or on releases.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidate3 of 3 tests fail on main with assertions on and off, 10.5.2 and 9.12.3 (FILTER conjunction 1 vs 0; equals and toString; query cache 0 vs 1); the clone fix passes 3 of 3.
Cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn test fails 2 of 3 on main (on and off), 10.5.2 and 9.12.3; found FILTER [102,103] plus MUST_NOT [12,13] giving 0 instead of 1; the control passes; the fix passes, and TestRangeFieldsDocValuesQuery 6 of 6. Works-as-designed case weak.
Independent reproducer realOwn test, all four types: 4 of 6 fail on main, 10.5.2 and 9.12.3 with assertions on and off; both controls pass; the fix makes 6 of 6 pass. Noted that the FILTER over-match depends on clause order.
Cross-model review, works-as-designed lens (OpenAI GPT-5.6) real, highOwn contract test fails 2 of 2 on main, 10.5.2 and 9.12.3 and passes with the fix. Works-as-designed case weak.
Devil's advocate real, moderate-highFound the scratch-array OR loop (0 hits instead of 1, no cache; the points control passes) on main (on and off), 10.5.2 and 9.12.3; weighed LUCENE-6321 and the setQueryCache note and found neither covers this.
Adjudication confirmed, highRe-ran the independent reproduction's test with seed 0BADF00D: 4 of 6 fail with assertions on and off; 6 of 6 pass with the fix.
Follow-up (history and cache probe) affected 8.2.0 to mainVersion-portable probe on 8.1.1 (API absent) to main; a security-screen probe of the MUST_NOT and cache paths fails 3 of 3 on main, 10.5.2 and 9.12.3 and passes with the fix.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with assertions on and off and passes with the fix shadowed.
This doc (gradle, fixes) reproduced, fix validatedRepro fails on 645d4fe14a20 and on 9.1.0 to 10.6.0; both fixes pass it; the recommended one passes 576 core document and related search tests and the lint checks.

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

Finding record

LFV-141, snapshot of 2026-11-06

Registry title
Doc-values range queries from *RangeDocValuesField.newSlowIntersectsQuery base equals/hashCode on the caller's min/max arrays but match the bounds copied at construction, so queries built from reused arrays compare equal while matching different ranges
Area
document / range-field doc-values queries (Int/Long/Float/DoubleRangeSlowRangeQuery)
Priority
Low, rank 868: Queries from the *RangeDocValuesField.newSlowIntersectsQuery factories compare equal on the caller's mutable arrays, so reusing those arrays makes BooleanQuery silently drop or merge clauses, but it needs a rarely used factory with no known caller plus array reuse.
Impact class
silent wrong results
Impact note
Applications that call Int/Long/Float/DoubleRangeDocValuesField.newSlowIntersectsQuery and then write new bounds into the same min/max arrays to build another such query on the same field (for example a loop that ORs or excludes several windows through one scratch array pair); default IndexWriterConfig, codec and searcher, with -ea and -da; BooleanQuery then silently drops or merges the clauses that compare equal (an OR of two windows, a FILTER conjunction or a pair of MUST_NOT windows returns the wrong count, and a FILTER plus MUST_NOT pair rewrites to no hits) with no query cache involved, and a query cache can serve one query's cached hits for the other (the JVM-wide default cache on 9.x and 10.x; on main only a cache the application installs); ordinary scale; released 8.2.0-10.6.0; a rarely used API with no known caller in Lucene (tests only), Elasticsearch, OpenSearch or Solr.
Summary
You build a doc-values range query with, for example, IntRangeDocValuesField.newSlowIntersectsQuery(field, min, max), then write new values into the same min and max arrays and build a second query, as a loop that ORs several ranges through one scratch array pair does. You expect two queries that match different ranges to be different queries, as they are with the points factory IntRange.newIntersectsQuery. Instead each query matches the bounds it encoded when it was built but compares equal to (and prints) whatever the arrays hold now, so BooleanQuery de-duplicates the 'equal' clauses and a query cache can hand one query's result to the other: an OR of [0,3] and [5,6] built this way finds 0 hits on a document with range [5,10] instead of 1.
Introduced
f84afab0083f (master, later 9.0) and its branch_8x backport d2ff7ffafcd0 (released in 8.2.0), both 'LUCENE-8362: Introduce DocValues Fields and Range Queries for native Range Field Types', authored by atris and committed by jpountz; contributed as patches on JIRA LUCENE-8362 (migrated issue #9409), no GitHub PR. The defect came with the API: the four *RangeSlowRangeQuery constructors (this.min = min; this.max = max) and their equals/hashCode are unchanged since that commit [read: git show and git blame in the full-history clone; later commits to the four files are only the LUCENE-9570 reformat 2d6ad2fee6d and #11840 6cde41c9fd8, which touch neither the fields nor equals/hashCode; exec: 8.1.1 has no such API, 8.2.0 fails; no commit-level bisect needed, because the parent commits have no such API] (2019-06-10 (commit date on master and branch_8x; author date 2019-05-27))
First affected version
8.2.0 [exec]
Affected versions
8.2.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 and main ea57360912ed (unreleased 11.0) [exec: the minimal test fails on the 10.6.0 jars with -ea (2026-10-31); the follow-up's history probe (all four types, a fresh-array control per type) shows the defect on 8.2.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (10.5.2 and main also -da); the candidate JUnit fails 3 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (main also -da) and an 8.x-adapted copy fails 3 of 3 on 8.2.0; the independent reproduction's, the cross-model review's and the intended-behaviour check's own tests fail on 10.5.2, 9.12.3 and main]; the other releases in these ranges are inferred from the four classes being present with unchanged code in every SHA-1-verified lucene-core jar from 8.2.0 to 10.5.2 [read]; not affected: 8.1.1 and earlier, where the API does not exist (the repro does not compile on 8.1.1 [exec]; the classes are absent from the 7.7.0-8.1.1 jars [read]); not fixed on main [read]
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, the prior-report record, read-only: a scripted prior-report search with 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries; 19 manual gh searches on apache/lucene and on Elasticsearch, OpenSearch and Solr; the changed-file lists of all 413 open apache/lucene PRs; 14 issues and PRs read with all comments, including all 35 comments of #9409; manual lists.apache.org and JIRA REST searches; the upstream commit history of the four files on main and branch_10x; plus the finder's and the intended-behaviour check's scripted prior-report searches); related prior items, none of them this bug: #9409 (LUCENE-8362, origin of the classes; its review asked only for Arrays.equals instead of identity on arrays), #7429 (LUCENE-6369), #7382 (LUCENE-6321) and #7648 (LUCENE-6590), the 2015 general policy that queries must be immutable or copy mutable input for the query cache, #12158 (merged clone-in-factory fix for caller arrays in KeywordField#newSetQuery, a different class), #8361, #10322, #11950, #16657; open PRs #16847, #14475 and #13948 touch the parent class BinaryRangeFieldRangeQuery or BinaryRangeDocValues but not the four subclasses and would not fix it
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off; with assertions on, it also fails on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. The clone fix passes it, together with 576 core document and related search tests and the core format, ECJ and forbidden-apis checks. The hunt, the verifiers and the adjudication ran main ea57360912ed, and a refresh re-ran the repros on a9779ef3c193; the files involved are identical in all three.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/IntRangeSlowRangeQuery.java:28-56 (constructor passes encodeRanges(min, max) to super and stores this.min = min, this.max = max; equals/hashCode on min and max); LongRangeSlowRangeQuery.java:32-58, FloatRangeSlowRangeQuery.java and DoubleRangeSlowRangeQuery.java (same code)
  • lucene/core/src/java/org/apache/lucene/document/BinaryRangeFieldRangeQuery.java:66-82 (parent equals/hashCode on the encoded queryPackedValue, overridden by the subclasses)
  • lucene/core/src/java/org/apache/lucene/document/IntRangeDocValuesField.java:58-72 (newSlowRangeQuery/newSlowIntersectsQuery pass the caller's arrays through; javadoc says only that the query may be slow)
  • lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (equals: 'required so that QueryCache works properly'; equal queries have identical document-filtering properties)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:134-141 (FILTER and MUST_NOT clauses kept in HashSets), :362-369 (FILTER clause also in MUST_NOT rewrites to MatchNoDocsQuery)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84 (DEFAULT_QUERY_CACHE = null on main), :262-272 (setQueryCache NOTE: queries should not be modified after they have been passed to IndexSearcher)
  • lucene/core/src/java/org/apache/lucene/index/Term.java:170-175 and search/MultiPhraseQuery.java:109-113 (where Lucene keeps caller-owned data it says so); document/NumericDocValuesField.java:127-129 (newSlowSetQuery clones its values); document/LongRange.java:246 (the points factory encodes into fresh bytes)
  • SECURITY.md:28, 50 (threat model)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/IntRangeDocValuesField.java:58-72; document/{Long,Float,Double}RangeDocValuesField.java:58-72 (main 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/document/IntRangeSlowRangeQuery.java:31-56, :65-77, :84-89; document/{Long,Float,Double}RangeSlowRangeQuery.java:32-58, :67-79
  • lucene/core/src/java/org/apache/lucene/document/BinaryRangeFieldRangeQuery.java:46-53, :66-82, :123-129
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:129-141, :343-361, :365-369, :442-447, :714-758; search/Multiset.java:33, :80-84; search/IndexSearcher.java:82-84; search/Query.java:95-107
  • a Lucene clone releases/lucene/10.6.0 and releases/lucene/10.5.2: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:83-99, :131, :169 (the static default LRUQueryCache; setDefaultQueryCache)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/IntRangeSlowRangeQuery.java:44 (equals compares the field); search/IndexSearcher.java:82-84; releases/lucene/10.5.2 search/LRUQueryCache.java:167-197 and search/UsageTrackingQueryCachingPolicy.java:116-138 (the default cache's checks)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:95-107; document/BinaryRangeFieldRangeQuery.java:66-82; document/IntRange.java:202-204, :254
  • lucene/core/src/java/org/apache/lucene/document/NumericDocValuesField.java:127-128; document/SortedNumericDocValuesField.java:125-126; index/Term.java:50-54; search/MultiPhraseQuery.java:109-113
  • lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:97; search/KnnByteVectorQuery.java:96; search/IndexSearcher.java:268-269
  • https://github.com/apache/lucene/pull/12158, https://github.com/apache/lucene/issues/7429, https://github.com/apache/lucene/issues/7382 (comment of 2015-03-03 and the closing comment "Closing in favor of #7429", read 2026-10-31; #7429's "Superseded by #7648", read 2026-10-31), https://github.com/apache/lucene/issues/9409
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/{Int,Long,Float,Double}RangeDocValuesField.java:61 (the only constructor callers)
Prior reports
  • https://github.com/apache/lucene/issues/9409, https://github.com/apache/lucene/pull/12158, https://github.com/apache/lucene/issues/7429

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

Repro files

About the diagrams

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

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