The range query runs its fast-match query but hides it from QueryVisitor, and the clause limit only counts what a visitor sees.
- After rewriting a query,
IndexSearcherwalks the query tree with aQueryVisitorand throwsTooManyNestedClausesonce the total count is pastmaxClauseCount(1024 by default; it tests before it increments, so the 1,026th clause throws). - Each query's
visitpasses the visitor on to its child queries; aBooleanQuerydoes this for every clause, so nested clauses add up. A query with no child queries reports itself once, a term query throughconsumeTermsand others throughvisitLeaf; each report counts as one clause. 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.
- Build a fast-match query of 1,200 term clauses (two required BooleanQueries of 600
TermQuerySHOULD clauses each) and pass it tonew LongRange("r", 0, true, 10, true).getQuery(fastMatch, LongValuesSource.fromLongField("n")). Search with a defaultIndexSearcher, whosemaxClauseCountis 1024. - 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. - 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
visitcalls onlyvisitLeaf(this)and never passes the visitor to the fast-match query, which it still runs as its filter. - 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.
- Other visitors are blind too:
QueryVisitor.termCollectorfinds none of the fast-match terms, andRamUsageEstimator.sizeOf(query, 32), the estimate an installed query cache uses, gives 32 bytes against 211,232 for the fast-match query alone.
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.
- The leaf-only visit.
ValueSourceQuery.visitandMultiValueSourceQuery.visitarevisitor.visitLeaf(this);and nothing else (LongRange.java:137-140and:236-239,DoubleRange.java:150-153and:249-252).fastMatchQueryis read byequals,hashCode,rewriteandcreateWeight, but not byvisit. - It stays in the rewritten tree.
rewriterewrites 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). - The check sees only what
visitshows.IndexSearcher.rewrite(Query)rewrites to a fixpoint and callsquery.visit(getNumClausesCheckVisitor())(IndexSearcher.java:871-880); that visitor counts one clause pervisitLeaf,consumeTermsorconsumeTermsMatchingcall (:891-932; it tests before it increments, so 1,025 clauses pass, the known off-by-one #10339). The range query adds onevisitLeaf. - It runs anyway.
createWeightcallssearcher.createWeight(fastMatchQuery, ScoreMode.COMPLETE_NO_SCORES, 1f)(LongRange.java:159,:258;DoubleRange.java:172,:271) and uses its iterator as the approximation.IndexSearcher.createWeightassumes a rewritten query and checks nothing (IndexSearcher.java:970-976); that part is normal. - Only per-builder caps remain.
BooleanQuery.Builder.addrefuses 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. - Facet counting differs.
FacetCountsWithFilterQuery.createIteratorrunssearcher.rewrite(fastMatchQuery)first (FacetCountsWithFilterQuery.java:69-74), so range facet counts reject the fast-match query that the query path accepts.
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 throughDrillSideways. - 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
maxClauseCountto 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_11xand main.
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.
/*
* 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));
}
}
}
}
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestRangeQueryFastMatchClauseLimit -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
searcher.search(query, 1)throwsIndexSearcher.TooManyNestedClauses(1,200 fast-match clauses > 1024).- Observed
- No exception:
AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown, with assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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 211232Query#visitmust visit "any child queries", andTooManyNestedClausescounts clauses "cumulatively in all of its children".getQuerycalls 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.visitas 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:TooManyNestedClausesis 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): "
IndexSearchernow 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". getQueryjavadoc: the query "will check all documents that match the provided match query";fastMatchQueryis "a query to use as a filter". ATermQueryfast-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
visiton each of their sub queries with it. AndvisitLeafis for queries that don't match on terms." In #14759 they called QueryVisitor "a query introspection API". Neither review that wrote thesevisitbodies (#581, #543) discusses theirvisitor the fast-match query (#543's only comments on these files are about keeping the single-valuedgetQueryoverload). FacetCountsWithFilterQuery.java:74rewrites, and so checks, the same fast-match query; no upstream test asserts that it is hidden, andTestRangeFacetCountspasses 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.
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.
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
sizeOfincludes the fast-match query, as they already do for a FILTER clause; a visitor that ignores filters can skip it throughgetSubVisitor(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
TestDrillDownQueryandTestDrillSideways; - the demo's facet examples (9 classes, 19 tests, including
TestDistanceFacetsExampleandTestRangeFacetsExample): 0 failures; - facet
checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainpass.
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.
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.
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.
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.
Applications that rely on maxClauseCount alone to bound user-built fast-match queries passed to getQuery should bound those queries themselves.
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
IndexOrDocValuesQueryover-count (52 cases), so it is not vacuous. - A bytecode scan with the JDK ClassFile API listed
ValueSourceQuery/MultiValueSourceQuery.fastMatchQueryas read byrewriteandcreateWeightbut never byvisit. - 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-28 | the 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 bypass | no report of this bug |
| Review threads | 2026-10-28 | all 107 review comments of apache/lucene-solr#581 (LUCENE-3041) and the #543 (LUCENE-10245) threads | neither discusses the range queries or the fast-match query in visit |
| Open PRs | 2026-10-28 | changed-file lists of all 413 open PRs | only #16847 touches the two files (maxDoc to maxLeafDoc), no visit change |
| Mailing lists, JIRA archive, downstream, web | 2026-10-28 | dev@, java-user@, issues@, commits@; LUCENE JIRA (tool and 3 direct JQL); Elasticsearch, OpenSearch and Solr repositories; 8 web queries | no report |
| GitHub refresh | 2026-10-31 | 11 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-28 | 0 new hits (visitLeaf: 12 older, known items); only #14673, a closed branch-merge PR that keeps the leaf-only visit, touches DoubleRange.java |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20, release 10.6.0 | affected | the minimal test fails with assertions on and off |
main a9779ef3c193, 10.6.0 revision, 10.5.2, 9.12.3, 9.1.0 | affected | the 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) | affected | portable probe: the FILTER control is rejected, the range queries are accepted |
| 8.1.0 to 8.11.4 (26 releases) | latent | the visitor sees 1 leaf, but there is no cumulative check, so the control is accepted too |
| 8.0.0 | not affected | no QueryVisitor yet (compile error) |
fix on 645d4fe14a20 | not affected | the minimal test, TestRangeFacetCounts, the facet module and the demo's facet examples pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Differential 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), procedural | Could not run commands; named no flaw and rated the by-design case weak; a separate re-run fails 4 of 4. |
| Independent reproducer | real | Own 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 | real | Gradle 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 corrected | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 613 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,645 words
- 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 maina9779ef3c193(the cited source files are identical; onlyCHANGES.txtgained 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 main2f424fd428dandbranch_11x603af870375(2026-10-30) still have the same code.
- 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) @ a9779ef3c193lucene/facet/src/java/org/apache/lucene/facet/FacetCountsWithFilterQuery.java:74 (the facet counting path calls searcher.rewrite(fastMatchQuery)) @ a9779ef3c193GitHub 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 @ 645d4fe14a20lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:871-880, 891-932, 970-976 @ 645d4fe14a20lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:74-86 @ 645d4fe14a20lucene/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 @ 645d4fe14a20lucene/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 @ 645d4fe14a20lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:1193-1199 @ 645d4fe14a20lucene/MIGRATE.md:1531-1534; lucene/CHANGES.txt:4681-4684 (9.0.0), 5796-5800 (8.6.0 Bug Fixes) @ 645d4fe14a20lucene/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))
- TestRangeQueryFastMatchClauseLimit.java (one test, public API):
repro/LFV-917/src/org/apache/lucene/facet/range/TestRangeQueryFastMatchClauseLimit.java(59 lines)
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).
assets/LFV-917/bug.png(613 KB)
