All findings · Lucene finding · LFV-917 · facet-range-query-visit-hides-fastmatch-query

LongRange/DoubleRange#getQuery hides its fast-match query from QueryVisitor, so maxClauseCount never counts it

A range query from LongRange/DoubleRange#getQuery(fastMatchQuery, ...) runs its fast-match query as a filter but shows QueryVisitor only itself, so IndexSearcher's clause limit (1024) never counts the fast-match query: 1,200 fast-match clauses are accepted, while the same clauses as a FILTER clause are rejected. Hits match the FILTER form.

Summary

Priority
Low, rank 736The experimental LongRange/DoubleRange getQuery hides its fast-match query from QueryVisitor, a much narrower case of LFV-038 and LFV-872 that matters only for an unusually large fast-match query, so it closes the bounded-guard rows after LFV-348.
Impact
minor
Since
9.0.0 (2021); the code is from 8.1.0introduced 2019-03-14 · about 7 years
Affected
every release 9.0.0 to 10.6.0, branch_11x and main
Status
documented
Found by
semi-formal: differential check of the clause limit against a model, then a public-API 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

The range query runs its fast-match query but hides it from QueryVisitor, and the clause limit only counts what a visitor sees.

Background: how Lucene counts clauses

  1. After rewriting a query, IndexSearcher walks the query tree with a QueryVisitor and throws TooManyNestedClauses once the total count is past maxClauseCount (1024 by default; it tests before it increments, so the 1,026th clause throws).
  2. Each query's visit passes the visitor on to its child queries; a BooleanQuery does this for every clause, so nested clauses add up. A query with no child queries reports itself once, a term query through consumeTerms and others through visitLeaf; each report counts as one clause.
  3. LongRange/DoubleRange#getQuery(fastMatchQuery, valuesSource) returns a query that only checks documents its fast-match query matches: the fast-match query is a filter it runs inside.
Diagram of the bug: a fast-match query of two BooleanQueries with 600 term clauses each. Placed as a FILTER clause next to a range query, IndexSearcher's clause-counting visitor counts 1,201 clauses and throws TooManyNestedClauses (limit 1024). Passed to LongRange#getQuery, the range query's visit reports only itself, the visitor counts 1 clause and the search runs, although the range query executes the same 1,200 clauses as its filter.
The example query: the clause-counting visitor walks all 1,200 terms of a FILTER clause (1,201 in total, rejected) but stops at the range query (1, accepted), although the range query runs the same 1,200-clause fast-match query as its filter. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a fast-match query of 1,200 term clauses (two required BooleanQueries of 600 TermQuery SHOULD clauses each) and pass it to new LongRange("r", 0, true, 10, true).getQuery(fastMatch, LongValuesSource.fromLongField("n")). Search with a default IndexSearcher, whose maxClauseCount is 1024.
  2. You expect TooManyNestedClauses, as you get when the same 1,200 clauses are a FILTER clause next to the range query (1,201 clauses counted) or are searched alone.
  3. Instead the search runs, with assertions on or off, and the hits are those of the FILTER form. The clause-counting visitor counts the range query as 1 clause: its visit calls only visitLeaf(this) and never passes the visitor to the fast-match query, which it still runs as its filter.
  4. Nothing else checks the fast-match query on this path, so one range query accepts a fast-match tree with no cumulative clause limit (each BooleanQuery inside it is still capped at 1024). Range facet counting with the same fast-match query does throw.
  5. Other visitors are blind too: QueryVisitor.termCollector finds none of the fast-match terms, and RamUsageEstimator.sizeOf(query, 32), the estimate an installed query cache uses, gives 32 bytes against 211,232 for the fast-match query alone.

Who is affected

Applications that use the experimental LongRange/DoubleRange#getQuery(fastMatchQuery, ...) (single- or multi-valued), for example to drill down on a range facet, with default settings. It shows only when the fast-match query holds more than 1024 clauses in total, or when a visitor (term collection, visitor-based highlighting, an installed query cache's RAM estimate) inspects it. Every release since 9.0.0.

Mechanism, terms and numbers489 words

LongRange#getQuery(fastMatchQuery, valuesSource) and its DoubleRange and multi-valued twins return a query that only checks documents its fast-match query matches; the javadoc calls fastMatchQuery "a query to use as a filter". The query runs the fast-match query as its two-phase approximation, but its visit is just visitor.visitLeaf(this). IndexSearcher enforces maxClauseCount by walking the rewritten query with a QueryVisitor, so it counts the range query as one clause and never sees the fast-match query, not even on its own. With the default limit of 1024, a range query whose fast-match query holds 1,200 term clauses is searched without error, while the same clauses as a FILTER clause, searched alone or passed to IndexSearcher#rewrite, are rejected; range facet counting with the same fast-match query rejects it too. QueryVisitor.termCollector and RamUsageEstimator.sizeOf (an installed query cache's RAM estimate) also miss it. No wrong hits were seen: with the limit raised, the hits are those of the FILTER form. Every release since 9.0.0 is affected; the fix is to visit the fast-match query with a FILTER sub-visitor.

Mechanism (main 645d4fe14a20)

  1. The leaf-only visit. ValueSourceQuery.visit and MultiValueSourceQuery.visit are visitor.visitLeaf(this); and nothing else (LongRange.java:137-140 and :236-239, DoubleRange.java:150-153 and :249-252). fastMatchQuery is read by equals, hashCode, rewrite and createWeight, but not by visit.
  2. It stays in the rewritten tree. rewrite rewrites the fast-match query and, if it changed, returns a new range query that holds the rewritten one (LongRange.java:142-151, :241-250; DoubleRange.java:155-164, :254-263).
  3. The check sees only what visit shows. IndexSearcher.rewrite(Query) rewrites to a fixpoint and calls query.visit(getNumClausesCheckVisitor()) (IndexSearcher.java:871-880); that visitor counts one clause per visitLeaf, consumeTerms or consumeTermsMatching call (:891-932; it tests before it increments, so 1,025 clauses pass, the known off-by-one #10339). The range query adds one visitLeaf.
  4. It runs anyway. createWeight calls searcher.createWeight(fastMatchQuery, ScoreMode.COMPLETE_NO_SCORES, 1f) (LongRange.java:159, :258; DoubleRange.java:172, :271) and uses its iterator as the approximation. IndexSearcher.createWeight assumes a rewritten query and checks nothing (IndexSearcher.java:970-976); that part is normal.
  5. Only per-builder caps remain. BooleanQuery.Builder.add refuses a 1025th clause in one BooleanQuery (BooleanQuery.java:74-86), but the total of a tree of such BooleanQueries is never counted on this path.
  6. Facet counting differs. FacetCountsWithFilterQuery.createIterator runs searcher.rewrite(fastMatchQuery) first (FacetCountsWithFilterQuery.java:69-74), so range facet counts reject the fast-match query that the query path accepts.

The numbers

Default IndexSearcher, maxClauseCount 1024, fast-match query = two MUST BooleanQueries of 600 SHOULD term clauses. Clauses a QueryVisitor counts: fast-match query alone 1,200, the range query 1, BooleanQuery(MUST range, FILTER fastMatch) 1,201. The FILTER form, the fast-match query alone and searcher.rewrite(fastMatch) throw; LongRange/DoubleRange#getQuery with single- and multi-valued sources run, also inside a DrillDownQuery and through DrillSideways. With the limit raised, hits are the same as for the FILTER form. RamUsageEstimator.sizeOf(rangeQuery, 32) is 32 bytes with a 1-term or the 1,200-leaf fast-match query; the fast-match query alone is 211,232 bytes.

Exposure and likelihood190 words
  • Who. Users of the four getQuery(fastMatchQuery, ...) overloads (opt-in, @lucene.experimental). The facet demo uses one to drill down on a distance range: DrillDownQuery.add("field", range.getQuery(boundingBox, vs)), searched through DrillSideways.
  • When. A fast-match query is usually a small filter (the demo's is a bounding box), so the limit only matters for unusually large ones; the visitor effects matter when an application, a visitor-based highlighter (inferred from the code, not run) or an installed query cache (caching is off by default) inspects such a query.
  • Effect. An application that relies on maxClauseCount to bound the queries it builds gets no limit on clauses inside a fast-match query; term collection under-reports, and so does an installed query cache's RAM estimate; the query and the facet counting path disagree on the same fast-match query.
  • Not affected. No wrong hits were seen: with the limit raised, the hits are those of the FILTER form (scores were not compared). Each BooleanQuery inside the fast-match query is still capped when built, and the work stays proportional to the query the application built.
  • Versions. Every release since 9.0.0 (multi-valued overloads since 9.1.0), 10.6.0, branch_11x and main.

Minimal repro

One JUnit test, one empty document, public API: a LongRange query with a 1,200-clause fast-match query, searched with the default 1024-clause limit. The test expects TooManyNestedClauses.

TestRangeQueryFastMatchClauseLimit.java (one test, public API)59 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.facet.range;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.LongValuesSource;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestRangeQueryFastMatchClauseLimit extends LuceneTestCase {

  public void testFastMatchQueryCountsTowardsClauseLimit() throws IOException {
    BooleanQuery.Builder fastMatch = new BooleanQuery.Builder();
    for (int i = 0; i < 2; i++) {
      BooleanQuery.Builder terms = new BooleanQuery.Builder();
      for (int j = 0; j < 600; j++) {
        terms.add(new TermQuery(new Term("f", i + "_" + j)), Occur.SHOULD);
      }
      fastMatch.add(terms.build(), Occur.MUST);
    }
    Query query =
        new LongRange("r", 0, true, 10, true)
            .getQuery(fastMatch.build(), LongValuesSource.fromLongField("n"));
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocument(new Document());
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        // expected: TooManyNestedClauses (1200 fast-match clauses > 1024); actual: no exception
        expectThrows(IndexSearcher.TooManyNestedClauses.class, () -> searcher.search(query, 1));
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestRangeQueryFastMatchClauseLimit -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
searcher.search(query, 1) throws IndexSearcher.TooManyNestedClauses (1,200 fast-match clauses > 1024).
Observed
No exception: AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown, with assertions on and off.

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

More files, environment, full output246 words

The clause check runs in IndexSearcher.rewrite, before any field is read, and for this BooleanQuery/TermQuery shape the rewrite does not depend on the index, so the index content does not matter here. The test nests two BooleanQuery disjunctions because one BooleanQuery cannot be built with more than 1024 clauses and the check throws only past 1,025 counted clauses; 1,200 is well clear of both. The test uses the single-valued LongRange overload; DoubleRange and both multi-valued overloads have the same visit body and the same fix. Deterministic: 10 of 10 iterations of a second seed fail the same way.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT) and release 10.6.0: fails; with the fix: passes. The code is unchanged on main and branch_11x as of 2026-10-30
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestRangeQueryFastMatchClauseLimit.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/range/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestRangeQueryFastMatchClauseLimit -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed); -Ptests.asserts=false identical
> Task :lucene:facet:test FAILED

TestRangeQueryFastMatchClauseLimit > testFastMatchQueryCountsTowardsClauseLimit FAILED
    junit.framework.AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:9B02CD11DC2AA721]:0)
        at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at org.apache.lucene.facet.range.TestRangeQueryFastMatchClauseLimit.testFastMatchQueryCountsTowardsClauseLimit(TestRangeQueryFastMatchClauseLimit.java:55)

# standalone runner: tests_run=1 failures=1 on the 645d4fe14a20 jars and the 10.6.0 release jars, -ea and -da

# QueryVisitor leaf counts (an independent reproducer's test): fast-match query 1200, range query 1,
# BooleanQuery(MUST range, FILTER fastMatch) 1201; sizeOf(range query, 32) = 32 bytes, fast-match query alone 211232

Why it looks unintended

  • Query#visit must visit "any child queries", and TooManyNestedClauses counts clauses "cumulatively in all of its children".
  • getQuery calls the fast-match query "a query to use as a filter"; it changes the hits, and as a FILTER clause it is counted.
  • Lucene recorded the same gap in DocValuesRewriteMethod.visit as a bug (LUCENE-8849), and range facet counting already checks this query.
The full argument299 words
  • Query.java:88-93: "Recurse through the query tree, visiting any child queries."
  • IndexSearcher.java:1193-1199: TooManyNestedClauses is thrown "when a client attempts to execute a Query that has more than getMaxClauseCount() total clauses cumulatively in all of its children".
  • MIGRATE.md (LUCENE-8811): "IndexSearcher now performs max clause count checks on all types of queries (including BooleanQueries)." CHANGES 9.0.0: the checks "are now implemented using a QueryVisitor and apply to all queries, rather than only booleans". In #9855, Adrien Grand liked that it "removes ways to by-pass the maximum clause count by splitting boolean queries into multiple sub boolean queries".
  • getQuery javadoc: the query "will check all documents that match the provided match query"; fastMatchQuery is "a query to use as a filter". A TermQuery fast-match query cuts the hits from 20 to 1 in a verifier's probe.
  • CHANGES 8.6.0, Bug Fixes: "LUCENE-8849: DocValuesRewriteMethod.visit wasn't visiting its embedded query"; its patch chose FILTER for the embedded query "as the rewritten query is really a filter" (Michele Palmia on #9892), as the fix here does, but it only calls visitor.getSubVisitor(BooleanClause.Occur.FILTER, query) and drops the result, so that query is still not visited on main (DocValuesRewriteMethod.java:73-77; the hunt's LFV-038, open PR #13140).
  • In the QueryVisitor PR (LUCENE-3041), its author Alan Woodward said "all compound queries should pull a sub visitor and call visit on each of their sub queries with it. And visitLeaf is for queries that don't match on terms." In #14759 they called QueryVisitor "a query introspection API". Neither review that wrote these visit bodies (#581, #543) discusses their visit or the fast-match query (#543's only comments on these files are about keeping the single-valued getQuery overload).
  • FacetCountsWithFilterQuery.java:74 rewrites, and so checks, the same fast-match query; no upstream test asserts that it is hidden, and TestRangeFacetCounts passes with the fix.
The devil's-advocate check187 words

Strongest case for "intended" (built by a verifier): the fast-match query is an execution detail (LUCENE-6648 described getQuery as "a fast approximation and a slow confirmation"); other wrappers such as DrillSidewaysQuery, the block-join queries and CompositeVerifyQuery are opaque leaves too; Alan Woodward said in LUCENE-8811 that the visitor-based count "wouldn't be exact"; open PR #16175 calls IndexOrDocValuesQuery's doc-values variant "an implementation detail"; the classes are experimental.

Why it fails: IndexOrDocValuesQuery's two variants match the same documents, so visiting both double-counts; the fast-match query decides which documents can match, exactly like a FILTER clause, which is counted. LUCENE-8849 recorded this kind of gap as a bug, the API's author calls it an introspection API, and facet counting already checks the same query. The other opaque leaves are unreported, not endorsed: the verifier found no CHANGES, MIGRATE, javadoc or won't-fix text that accepts hiding an executed sub-query.

An independent fact check by another model family (OpenAI GPT-5.6) still rates the "intended" case moderate: the API is experimental, the application builds the filter, and the fix makes some accepted queries throw. It is a small, compatibility-sensitive report.

Suggested fix

Recommended Visit the fast-match query as a FILTER child

In all four visit bodies, keep visitLeaf(this) and also visit the fast-match query with visitor.getSubVisitor(BooleanClause.Occur.FILTER, this), the way a FILTER clause is visited (3 lines each). Validated: the repro passes, and so do TestRangeFacetCounts, the facet module and the demo's facet examples.

@Override
public void visit(QueryVisitor visitor) {
  visitor.visitLeaf(this);
  if (fastMatchQuery != null) {
    fastMatchQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
  }
}
All options and trade-offs4 options · 426 words

One option was built and validated; the others are listed with their trade-offs.

Visit the fast-match query as a FILTER child (validated by the checks) (recommended)

In ValueSourceQuery and MultiValueSourceQuery of LongRange.java and DoubleRange.java, visit keeps visitor.visitLeaf(this) and, when fastMatchQuery != null, calls fastMatchQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this)) (+7 lines per file, one import). The count becomes 1,201, the same as BooleanQuery(MUST range, FILTER fastMatch); FILTER is the occur LUCENE-8849 chose; unlike that patch, this one passes the sub-visitor on.

@Override
public void visit(QueryVisitor visitor) {
  visitor.visitLeaf(this);
  if (fastMatchQuery != null) {
    fastMatchQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
  }
}

Trade-offs

  • Behaviour change. Range queries with a fast-match query over the limit start to throw TooManyNestedClauses; maintainers may want it on main only, with a CHANGES entry.
  • Visitors see more. Term collectors (and, from the code, highlighters and the monitor module's query analysis) start to see the fast-match terms, and sizeOf includes the fast-match query, as they already do for a FILTER clause; a visitor that ignores filters can skip it through getSubVisitor(FILTER, ...).

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31):

  • stock: the minimal test fails with assertions on and off;
  • with the fix: the minimal test passes with assertions on and off;
  • TestRangeFacetCounts: 22 tests, 0 failures;
  • the whole facet module: 40 classes (39 upstream and the repro), 307 tests, 0 failures, 7 skipped, including TestDrillDownQuery and TestDrillSideways;
  • the demo's facet examples (9 classes, 19 tests, including TestDistanceFacetsExample and TestRangeFacetsExample): 0 failures;
  • facet checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain pass.

The hunt's version of the same change passed its own 4 tests, an independent reproducer's 8 tests and a verifier's 5 probes.

Visit only the fast-match query (not built)

visitLeaf(this) only when there is no fast-match query; otherwise visit the fast-match query as a FILTER child.

Trade-offs

Counts 1,200 instead of 1,201 and hides the range query's own doc-values condition from visitors that look for leaves, so it is less faithful than the recommended option.

Check the fast-match query in createWeight (not recommended)

Call searcher.rewrite(fastMatchQuery) before searcher.createWeight(...), as facet counting does.

Trade-offs

Checks the fast-match query only on its own, not together with the rest of the query; leaves term collection, highlighting and sizeOf blind; re-runs a rewrite on an already rewritten query. Calling createWeight on a rewritten query is not itself defective.

Document it (not a fix)

State in the getQuery javadoc that the fast-match query is not counted.

Trade-offs

Contradicts the Query#visit and TooManyNestedClauses contracts the query inherits.

Downstream note

Applications that rely on maxClauseCount alone to bound user-built fast-match queries passed to getQuery should bound those queries themselves.

How it was found

A bounded-exhaustive differential check compared IndexSearcher's clause limit with a model of the work each query really runs, over 16 wrapper sites (1,440 cases). The two range queries were the only sites never rejected even on their own; a bytecode scan showed that visit never reads fastMatchQuery, and a public-API JUnit confirmed it.

The method in full174 words

Method: semi-formal. The hunt unit looked for queries that execute an embedded query but hide it from QueryVisitor. Its harness ran the stock jars over 16 wrapper sites x primary leaves {1, 2, 4} x hidden leaves {1..10} x copies {1, 2, 3} = 1,440 checks at maxClauseCount 8, with assertions on and off, against a reference model that counts the clauses each query really executes.

  • Each range site under-counted in 42 of its 90 checks, and in the single-wrapper phase it was never rejected (fast-match query up to 16 leaves, where the model rejects from 9).
  • The BooleanQuery, ConstantScoreQuery and DisjunctionMaxQuery controls had 0 mismatches in 270, and the oracle flagged the known IndexOrDocValuesQuery over-count (52 cases), so it is not vacuous.
  • A bytecode scan with the JDK ClassFile API listed ValueSourceQuery/MultiValueSourceQuery.fastMatchQuery as read by rewrite and createWeight but never by visit.
  • The finding was reduced to a public-API JUnit, then checked by an independent reproducer, reviews by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication.

Prior reports, history and checks

Prior reports

No prior report found: 177 queries over GitHub, commits, JIRA, the mailing lists, downstream projects and the web, the review threads that wrote the code and a file scan of all open PRs (2026-10-28), re-checked 2026-10-31. Closest: LUCENE-8849 and #13140, the same kind of gap in other classes.

Searches5 searches

None of the related items reports this bug; they wrote the code, set the contract or show the same gap elsewhere.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-28the hunt's search tool in two runs plus 61 manual issue/PR, 5 commit queries, e.g. "fastMatchQuery" with QueryVisitor, clause, TooManyClauses, rewrite; LongRange/DoubleRange with visit, QueryVisitor, maxClauseCount; visitLeaf; TooManyNestedClauses bypassno report of this bug
Review threads2026-10-28all 107 review comments of apache/lucene-solr#581 (LUCENE-3041) and the #543 (LUCENE-10245) threadsneither discusses the range queries or the fast-match query in visit
Open PRs2026-10-28changed-file lists of all 413 open PRsonly #16847 touches the two files (maxDoc to maxLeafDoc), no visit change
Mailing lists, JIRA archive, downstream, web2026-10-28dev@, java-user@, issues@, commits@; LUCENE JIRA (tool and 3 direct JQL); Elasticsearch, OpenSearch and Solr repositories; 8 web queriesno report
GitHub refresh2026-10-3111 keyword searches (6 topic phrases and visitLeaf, 4 terms updated since 2026-10-28), the changed files of the 56 PRs updated since 2026-10-28, issue titles created since 2026-10-280 new hits (visitLeaf: 12 older, known items); only #14673, a closed branch-merge PR that keeps the leaf-only visit, touches DoubleRange.java

History

The leaf-only visit dates from the QueryVisitor API itself (LUCENE-3041, 8.1.0, 2019); it started to bypass the clause limit when LUCENE-8811 moved that limit onto QueryVisitor, first released in 9.0.0. Every release since is affected; it is not a regression.

Versions tested6 rows

Written by fbd05167f455 (LUCENE-3041 "QueryVisitor", apache/lucene-solr#581, Alan Woodward, 2019-03-14; 8.1.0); the multi-valued copy came with 82703757fe05 (LUCENE-10245, #543, Greg Miller, 2022-01-10; 9.1.0). Symptom since 53f56fb7ad44 (LUCENE-8811, #9855, 2019-06-26), whose 8.x backport was reverted before 8.2.0, so 9.0.0 is the first affected release.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20, release 10.6.0 affectedthe minimal test fails with assertions on and off
main a9779ef3c193, 10.6.0 revision, 10.5.2, 9.12.3, 9.1.0 affectedthe hunt's 4-test JUnit fails 4 of 4 (single- and multi-valued, Long and Double)
9.0.0 to 10.5.2 (all 33 releases) affectedportable probe: the FILTER control is rejected, the range queries are accepted
8.1.0 to 8.11.4 (26 releases) latentthe visitor sees 1 leaf, but there is no cumulative check, so the control is accepted too
8.0.0 not affectedno QueryVisitor yet (compile error)
fix on 645d4fe14a20 not affectedthe minimal test, TestRangeFacetCounts, the facet module and the demo's facet examples pass

Independent checks

Reproduced by the finder, an independent reproducer with its own test, a devil's-advocate verifier and an adjudication with fresh seeds, and re-run for this doc; reviews by OpenAI GPT-5.6 could only read code (one real, one procedural not-real with no flaw named). Controls pass, and the visit fix clears every repro.

Checks and verdicts308 words

Every check ran on stock jars with assertions on and off unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateDifferential harness (1,440 checks) and a 4-test JUnit: 4 of 4 fail on main, the 10.6.0 revision, 10.5.2 and 9.12.3; the fix passes.
Cross-model refute review (OpenAI GPT-5.6) not real (low), proceduralCould not run commands; named no flaw and rated the by-design case weak; a separate re-run fails 4 of 4.
Independent reproducer realOwn 8-test JUnit: 5 of 8 fail on main, 10.5.2 and 9.12.3; controls pass; leaves 1200/1/1201; the fix passes 8 of 8.
Works-as-designed lens (OpenAI GPT-5.6) real (medium)Read-only; agrees on the root cause and that the inputs are in contract; separate re-runs fail 4 of 4.
Devil's advocate real (medium-high)Built and rejected the by-design case; drill-down, drill-sideways and term-collection probes fail, the two controls pass, the fix passes 5 of 5.
Adjudication confirmed (high)Re-ran the independent reproduction's test with a new seed: 8 run, 5 failed, assertions on and off; set the class to minor.
This doc realGradle on 645d4fe14a20: the stock test fails with assertions on and off; with the fix it passes, and so do TestRangeFacetCounts, the facet module (307 tests), the demo's facet examples and lint. The 10.6.0 jars fail too.
Fact checks of this doc (2026-10-31) real; text correctedAn independent fact check re-ran the minimal test (fails on 645d4fe14a20 with assertions on and off and on the 10.6.0 jars; passes with the fix) and the brief's gradle command, and re-read the code, history and threads. A read-only review by OpenAI GPT-5.6 agreed the bug is real. Their corrections are applied: the LUCENE-8849 patch, #543's review, the counter's off-by-one, and the wording on hits, the query cache and highlighters.

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

Finding record

LFV-917, snapshot of 2026-11-06

Registry title
LongRange/DoubleRange#getQuery(fastMatchQuery, ...) range queries report themselves as a leaf in visit() and never visit their fast-match query, so IndexSearcher's maxClauseCount check never counts the fast-match query
Area
facet / range queries (LongRange/DoubleRange ValueSourceQuery and MultiValueSourceQuery QueryVisitor traversal; consumers: IndexSearcher's cumulative clause limit, QueryVisitor.termCollector, RamUsageEstimator.sizeOf(Query))
Priority
Low, rank 736: The experimental LongRange/DoubleRange getQuery hides its fast-match query from QueryVisitor, a much narrower case of LFV-038 and LFV-872 that matters only for an unusually large fast-match query, so it closes the bounded-guard rows after LFV-348.
Impact class
minor
Impact note
Applications that filter or drill down with the @lucene.experimental LongRange or DoubleRange#getQuery(fastMatchQuery, valuesSource) (single- or multi-valued sources), with the default searcher and default maxClauseCount (1024), get no TooManyNestedClauses for any clause inside the fast-match query: the cumulative check never counts it and, unlike the vector-similarity filter, it is not checked on its own either, so a single range query accepts a fast-match query of any total size (measured: 1,200 term clauses accepted, while the same query as a FILTER clause, searched alone or passed through IndexSearcher#rewrite is rejected; each BooleanQuery inside it is still capped by its builder), also through DrillDownQuery and DrillSideways; QueryVisitor.termCollector and RamUsageEstimator.sizeOf (the LRUQueryCache key estimate) also miss the fast-match query; search hits are correct, ordinary scale, every release since 9.0.0.
Summary
You build LongRange#getQuery(fastMatch, LongValuesSource) (or the DoubleRange or multi-valued overloads) with a fast-match query of 1,200 term clauses (two required 600-term disjunctions) and search it with the default maxClauseCount of 1024. You expect TooManyNestedClauses, which you do get when the same fast-match query is a sibling FILTER clause, because the check counts 'total clauses cumulatively in all of its children', Query#visit is documented to visit 'any child queries', and getQuery documents fastMatchQuery as 'a query to use as a filter'. Instead the search runs and returns the correct hits: ValueSourceQuery.visit and MultiValueSourceQuery.visit only call visitor.visitLeaf(this), so the fast-match query, which stays in the rewritten query and is executed as the approximation in createWeight, is seen by no clause check at all, and QueryVisitor.termCollector returns none of its terms.
Introduced
fbd05167f455e3ce2b2ead50336e2b9c2521cd6c (LUCENE-3041 'QueryVisitor', apache/lucene-solr#581, by GitHub romseygeek; branch_8x backport ecf86ecab7a34dc5fd77429b92a8a8b5d78f4844, released in 8.1.0) wrote ValueSourceQuery.visit as visitor.visitLeaf(this) in LongRange.java and DoubleRange.java, while the same commit kept rewrite() rewriting the fast-match query in place and createWeight() executing it; the symptom appeared with 53f56fb7ad44afab9c78fab2e89737438093247f (LUCENE-8811, #9855: the max clause check moved to IndexSearcher and was implemented with a QueryVisitor), first released in 9.0.0 (its branch_8x commit fa3bf88783ac was reverted by 2885a115782b and 193c77fe1a0a on 2019-07-15, before 8.2.0); the multi-valued MultiValueSourceQuery copied the same visit() in 82703757fe05a1d3b9efe28d766d4bc3df8a060f (LUCENE-10245, apache/lucene#543, by GitHub gsmiller; branch_9x 60b499b907d3), released in 9.1.0 [read: git blame on main a9779ef3c193 attributes LongRange.java:138-139 and DoubleRange.java:151-152 to fbd05167f455 and LongRange.java:237-238 and DoubleRange.java:250-251 to 82703757fe05; the 107 review comments of apache/lucene-solr#581 and the threads of apache/lucene#543 never mention the range queries or fastMatchQuery; exec: the visit() leaf count is 1 from 8.1.0 on, and the clause-limit bypass appears in 9.0.0]; not a regression of a check that once worked, so no bisect (2019-03-14 (author date of fbd05167f455, the leaf-only visit; branch_8x backport committed 2019-03-15); 2019-06-26 (author date of 53f56fb7ad44, LUCENE-8811, which made visit() the basis of the clause limit); 2022-01-10 (author date of 82703757fe05, the multi-valued copy))
First affected version
9.0.0 [exec] (the first release with the visitor-based cumulative check: the probe's control is rejected while LongRange/DoubleRange#getQuery with the same fast-match query is accepted, -ea and -da); the multi-valued overloads from 9.1.0 [exec] (9.0.0 has no Multi*ValuesSource: compile error [exec]); the defective visit() exists since 8.1.0 but is latent in all 26 releases 8.1.0 to 8.11.4 [exec] (no cumulative clause check there); 8.0.0 has no QueryVisitor [exec: compile error]
Affected versions
All 33 releases 9.0.0 to 10.5.2 (single-valued from 9.0.0, multi-valued from 9.1.0), plus 10.6.0 (the branch_10_6 head 4ae946e5a9b, the RC1 revision) and main a9779ef3c193 [exec: the follow-up's portable probe (public API from 8.1.0 on) is AFFECTED in every release 9.0.0 to 10.5.2 (-da sweep; also -ea on 9.0.0) and LATENT in every release 8.1.0 to 8.11.4 (visit leaves: fast-match 1200, range query 1, FILTER control 1201, but no cumulative check, so the control is accepted too); the finder's 4-test JUnit fails 4 of 4 on 9.1.0 (-ea and -da), 9.12.3 (-da), 10.5.2 (-da), branch_10_6 (-da) and main (-ea and -da); the independent reproduction's 8-test JUnit fails 5 of 8, with its controls passing, on 9.12.3, 10.5.2 and main (each -ea and -da); the intended-behaviour check's probe fails 3 of 5 on 10.5.2 (-da) and main (-ea and -da); read: the four visit() bodies are unchanged since they were written]; upstream main c5ab3e57b9b (author date 2026-10-28), branch_11x, branch_10x and branch_10_6 still have the leaf-only visit (last commit on either file b59848c7049, #15842, author date 2026-04-02, does not touch visit) [read 2026-10-28]; not fixed anywhere. All runs on aarch64 Linux with JDK 25.
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, confidence 'high but not certain', searched 2026-10-28, read-only: a scripted prior-report search in two runs (26 GitHub issue/PR, 6 commit, 4 downstream Elasticsearch/OpenSearch/Solr, 56 mailing-list (dev@, java-user@, issues@, commits@), 8 JIRA-archive and 8 web queries; run 1's 6 web queries were deferred to run 2), 61 manual GitHub issue/PR searches, 5 commit searches and 3 JIRA queries, 177 queries in all, plus 10 direct reads; all 107 review comments of apache/lucene-solr#581 and the threads of apache/lucene#543 read in full; a changed-file scan of all 413 open apache/lucene PRs (only #16847, the long global doc ids PR, touches LongRange.java and DoubleRange.java: maxDoc to maxLeafDoc, no visit change), plus the finder's run and the independent reproduction's, the intended-behaviour check's and the adjudication's own searches. Refresh 2026-10-28: GitHub search 'fastMatchQuery visit' and '"fast match" QueryVisitor' 0 hits; 'LongRange ValueSourceQuery' only #13375 and #13383 (performance); 'visitLeaf', 'maxClauseCount', 'QueryVisitor' updated since 2026-10-26 and 'fastMatch' updated since 2026-10-25: 0; of the 69 PRs updated since 2026-10-26 (newest #16931), a changed-file scan finds only #14673 touching DoubleRange.java ('Merge 10.1 into 9.11', a 445-file branch-merge PR, closed unmerged, whose copy keeps visitLeaf(this)); the issues created up to #16928 contain nothing on facet range queries or visit; the last upstream commit on either file is still b59848c7049 (#15842, author date 2026-04-02). Related, not this bug: LUCENE-3041 (#4114, introduced the leaf-only visit; its review says 'all compound queries should pull a sub visitor'); LUCENE-10245 (#543, copied it); LUCENE-8811 (#9855, the visitor-based cumulative check: 'removes ways to by-pass the maximum clause count'); #14756 and open PR #16175 (the opposite over-count of IndexOrDocValuesQuery); LUCENE-8849 (#9892, DocValuesRewriteMethod.visit fixed with getSubVisitor(FILTER, ...)), open PR #13140 (registry LFV-038) and LUCENE-9050 (#10092), the same kind of omission in other classes; #13383/#13375 (same classes, performance); LUCENE-6648 (#7706, made the fast-match filter a Query). Gaps: GitHub search is keyword-based (the open-PR file scan finds fixes regardless of wording) and may not index line-review comments; the refresh hit the shared search rate limit once and the queries were retried after the reset.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; the release 10.6.0 jars fail the same way (2026-10-31). With the fix applied, the test, TestRangeFacetCounts, the facet module and the demo's facet examples pass. The hunt and its checks ran on main a9779ef3c193 (the cited source files are identical; only CHANGES.txt gained unrelated entries), the 10.6.0 revision, 10.5.2, 9.12.3 and 9.1.0, plus a probe sweep over every release 8.1.0 to 10.5.2. Upstream main 2f424fd428d and branch_11x 603af870375 (2026-10-30) still have the same code.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:138-159, 237-258, 310-344 and DoubleRange.java:151-172, 250-271, 323-357 @ a9779ef3c193 (visit, rewrite, createWeight, getQuery javadoc 'fastMatchQuery a query to use as a filter')
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:866-880 (rewrite runs getNumClausesCheckVisitor on the rewritten tree only), 1193-1199 (TooManyNestedClauses javadoc); Query.java:88-93 (visit javadoc) @ a9779ef3c193
  • lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:74 (the facet counting path calls searcher.rewrite(fastMatchQuery)) @ a9779ef3c193
  • GitHub REST/search (read-only), 2026-10-28: search/issues queries listed in upstream; repos/apache/lucene/pulls sorted by update (69 since 2026-10-26) and pulls/N/files for each; pulls/14673 (closed unmerged branch merge, no visit change); repos/apache/lucene/issues sorted by creation; repos/apache/lucene/commits?path=LongRange.java and DoubleRange.java; commits/main = c5ab3e57b9b
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:137-159, 236-258 @ 645d4fe14a20 (visit, rewrite, createWeight)
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:150-172, 249-271 @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:871-880, 891-932, 970-976 @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:74-86 @ 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:69-74 @ 645d4fe14a20
Who is affected
  • lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:239-244 @ 645d4fe14a20
  • lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:41, 310-338; DoubleRange.java:42, 323-351 @ 645d4fe14a20 (@lucene.experimental; javadoc)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:88-93 @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:1193-1199 @ 645d4fe14a20
  • lucene/MIGRATE.md:1531-1534; lucene/CHANGES.txt:4681-4684 (9.0.0), 5796-5800 (8.6.0 Bug Fixes) @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/search/DocValuesRewriteMethod.java:73-77 @ 645d4fe14a20 (the discarded FILTER sub-visitor)
  • lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:313-319 @ 645d4fe14a20 (getQuery javadoc)
  • lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1545-1546 @ 645d4fe14a20 (UsedQuery.visit)
  • GitHub (read-only): apache/lucene #9855, #9892, #14759, #16175, #7706; apache/lucene-solr#581
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:80, 369-371 @ 645d4fe14a20 (QUERY_DEFAULT_RAM_BYTES_USED = 1024; sizeOf(Query))

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 an instrumented run of the example (visitor counts, rewrite and search outcomes, with and without the fix). On a 3-document index, so the filter can be seen removing a document. Its sizeOf numbers use the one-argument RamUsageEstimator.sizeOf(query) (default 1,024 bytes per unknown query): 1,024 bytes for the range query against 1,402,624 bytes for the fast-match query alone. The text above uses sizeOf(query, 32), as the query cache does (32 against 211,232 bytes).

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.