The clause limit is checked once, by walking the query with a QueryVisitor. Function queries do not pass the visitor into their values source, so a query held there, such as boostByQuery's boost query, is not counted. It is checked later, on its own, when the search builds its weight.
- The clause limit.
IndexSearcherthrowsTooManyNestedClausesfor a query with more thanmaxClauseCountclauses (1,024 by default) counted over all its nested sub-queries. Since 9.0 it counts them once, after rewrite, by walking the query tree with aQueryVisitor. - Values sources. A function query computes a value per document from a values source (
DoubleValuesSourceorValueSource). Some sources hold a whole query and use its score:DoubleValuesSource.fromQuery(q),QueryValueSource(q), and the boost query ofFunctionScoreQuery.boostByQuery.
in (600 clauses) but stops at the values source; the boost query (600) is checked later on its own, so no check sees 1,200, while the same clauses in a BooleanQuery are rejected. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document (
f:a0,f:b0) and searchFunctionScoreQuery.boostByQuery(in, boostMatch, 2f), whereinis 600 SHOULD term clausesf:a0..f:a599andboostMatchis 600 more,f:b0..f:b599. The limit is the defaultmaxClauseCountof 1,024. - You expect
TooManyNestedClauses: both parts run (the boost query doubles the hit's score, 0.164 to 0.329), so 1,200 term clauses run, and aBooleanQueryholding the same 1,200 clauses (MUST in,SHOULD boostMatch) is rejected. - Instead the search runs and returns the hit. The limit is checked by walking the query with a
QueryVisitor, andFunctionScoreQuery.visitstops at the values source: the check counts 600. The boost query is checked later, alone, from 0: 600 again. - The same happens for
FunctionScoreQueryandFunctionMatchQueryoverDoubleValuesSource.fromQuery, and forFunctionQueryandFunctionRangeQueryoverQueryValueSource. Repetition and nesting multiply it: 100boostByQueryclauses with 1,000-clause boost queries run 100,100 term clauses, accepted.
Applications that put a large or repeated query inside a values source (boostByQuery, fromQuery, QueryValueSource) get no TooManyNestedClauses for it, with default settings. Hits and scores are not affected; only the guard rail is missing: about 2x the limit for one boostByQuery, a fresh full budget for every embedded query, and no bound when such queries repeat or nest.
Mechanism, terms and numbers514 words
IndexSearcher is documented to reject a query with more than maxClauseCount clauses (1,024 by default) "cumulatively in all of its children". It counts them once, after rewrite, by walking the query with a QueryVisitor. Some function queries hold a second query inside their values source: the boost query of FunctionScoreQuery.boostByQuery (the replacement for the removed BoostingQuery), any DoubleValuesSource.fromQuery, and QueryValueSource. Their visit() stops at the values source, which has no visit method, so those clauses are never added to the total. The embedded query still runs: it is rewritten when the weight is created, through searcher.rewrite, which checks it alone with a fresh count. So boostByQuery over two 600-term disjunctions runs 1,200 term clauses at the default limit, while the same clauses in a BooleanQuery are rejected, and repeating or nesting such queries multiplies the total without bound. Hits and scores are not affected; every release since 9.0.0 (LUCENE-8811) is affected. A counting-only visitor channel fixes it without changing what term extraction, highlighting or Monitor see.
- One check, after rewrite.
IndexSearcher.search(Query, int)(IndexSearcher.java:623) goes throughsearchAfter(:587,:601) tosearch(Query, CollectorManager)(:750), which rewrites the query (:753) throughrewrite(Query)(:871-880, reached via:884); it ends withquery.visit(getNumClausesCheckVisitor())(:878). The visitor (:896-932) returns itself for every occur, "even for MUST_NOT" (:902-905), and counts everyvisitLeaf,consumeTermsandconsumeTermsMatchingcall. Each call testsnumClauses > maxClauseCountbefore it increments, so one walk accepts 1,025 clauses at the default limit and throws at the 1,026th. - The walk stops at the values source.
FunctionScoreQuery.visitisin.visit(visitor.getSubVisitor(MUST, this))(FunctionScoreQuery.java:133-136);FunctionMatchQuery.visit(:80-83),FunctionRangeQuery.visit(:132-135) andFunctionQuery.visit(:92-95) arevisitor.visitLeaf(this).DoubleValuesSourceandValueSourcehave no visit method. - Where the boost query lives.
boostByQuery(FunctionScoreQuery.java:101-106) builds the sourceMultiplicativeBoostValuesSource(QueryBoostValuesSource(DoubleValuesSource.fromQuery(boostMatch), boost)). - It is checked later, alone. For a scoring search
FunctionScoreQuery.createWeightcallssource.rewrite(searcher)(:121), which reachesQueryDoubleValuesSource.rewrite(DoubleValuesSource.java:776-780) andsearcher.rewrite(query)(:779): step 1 again, on the boost query alone, with a new count that starts at 0.FunctionMatchQuery.createWeightdoes the same (:88), andQueryValueSource.createWeightcallssearcher.rewrite(q)(QueryValueSource.java:83) forFunctionRangeQuery(:149) andFunctionQuery(:71). A non-scoring search skips the boost query (FunctionScoreQuery.java:118-120).
boostByQuery(600, 600): the outer check counts 600, the boost query's own check 600; both pass and the hit scores 0.329 instead of 0.164, so the boost query ran. BooleanQuery(MUST 600, SHOULD 600): one check, 1,200, rejected. 32 and 100 boostByQuery clauses with 1,000-clause boost queries run 32,032 and 100,100 term clauses. With maxClauseCount 4, nesting boostByQuery 1, 2 and 3 levels deep builds 20, 84 and 340 leaf queries, and on main and 10.5.2 pulls a scorer for each (16, 64 and 256 on 9.12.3); each level gets a fresh budget, so the total grows exponentially with depth. An embedded query that is over the limit by itself is still rejected.
Exposure and likelihood146 words
- Who. Users of
FunctionScoreQuery.boostByQuery(the documented replacement forBoostingQuery, MIGRATE.md), ofFunctionScoreQueryorFunctionMatchQueryoverDoubleValuesSource.fromQuery, and of the@lucene.experimentalFunctionQueryandFunctionRangeQueryoverQueryValueSource, with the defaultIndexSearcherand limit. - What goes wrong. A query the documented cumulative check should reject is accepted. Nothing else: hits and scores are not affected, each embedded query is still checked on its own, and each
BooleanQueryis still capped when it is built. - Matching role.
FunctionMatchQueryandFunctionRangeQueryalways run the embedded query, and it decides which documents match.FunctionScoreQueryruns its boost query only on scoring searches. - Reach. No Lucene query parser builds these queries. Downstream function-query syntaxes can (for example a
query()function that builds aQueryValueSource). - Likelihood. Rare: it needs a large embedded query, and the only visible effect is a missing exception. No earlier report was found (see Prior reports).
One JUnit test on the public API: one document, a plain IndexSearcher, the default maxClauseCount of 1,024, and boostByQuery over two 600-term disjunctions, 1,200 term clauses. It must throw TooManyNestedClauses; nothing depends on the seed.
/*
* 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.queries.function;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
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.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 TestBoostByQueryClauseLimit extends LuceneTestCase {
private static Query disjunction(String prefix) {
BooleanQuery.Builder b = new BooleanQuery.Builder();
for (int i = 0; i < 600; i++) {
b.add(new TermQuery(new Term("f", prefix + i)), Occur.SHOULD);
}
return b.build();
}
public void testBoostQueryCountsTowardsMaxClauseCount() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new StringField("f", "a0", Field.Store.NO));
doc.add(new StringField("f", "b0", Field.Store.NO));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query query = FunctionScoreQuery.boostByQuery(disjunction("a"), disjunction("b"), 2f);
// 1200 term clauses > default maxClauseCount 1024: expected TooManyNestedClauses, actual none
expectThrows(IndexSearcher.TooManyNestedClauses.class, () -> searcher.search(query, 10));
}
}
}
}
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestBoostByQueryClauseLimit -Ptests.seed=DEADBEEF -Ptests.asserts=false
- Expected
searcher.search(query, 10)throwsIndexSearcher.TooManyNestedClauses(1,200 term clauses > 1,024).- Observed
- No exception, the search returns the hit:
AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown, with assertions on and off.
More files, environment, full output233 words
ByteBuffersDirectory, default IndexWriterConfig, one document with f:a0 and f:b0 so that both disjunctions match and the boost query really scores. Each part alone (600) is under the limit and the total (1,200) is far above it, so the test does not sit at the limit's boundary. It sits in org.apache.lucene.queries.function, like Lucene's own function-query tests; it fails with seeds DEADBEEF and 1234ABCD.
Release jars: the test fails on 9.12.3, 10.5.2 and 10.6.0 (assertions off). It needs org.apache.lucene.tests.util.LuceneTestCase, which exists from 9.1.0; on 9.0.0 a variant with the old import fails the same way.
Regression test: this same test; it passes with the recommended fix and fits as one more method of the existing TestFunctionScoreQuery.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails, assertions on and off; with the fix: passes. Releases 9.12.3, 10.5.2 and 10.6.0: fail |
|---|---|
| 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/TestBoostByQueryClauseLimit.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestBoostByQueryClauseLimit -Ptests.seed=DEADBEEF -Ptests.asserts=false
# the same with -Ptests.asserts=true
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed)
TestBoostByQueryClauseLimit > testBoostQueryCountsTowardsMaxClauseCount FAILED
junit.framework.AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown
at __randomizedtesting.SeedInfo.seed([DEADBEEF:9AEB3E5629C00982]:0)
at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at org.apache.lucene.queries.function.TestBoostByQueryClauseLimit.testBoostQueryCountsTowardsMaxClauseCount(TestBoostByQueryClauseLimit.java:56)
1 test completed, 1 failed
# the same failure with -Ptests.asserts=false
# with the recommended fix (both settings)
1 test, 0 failures (TooManyNestedClauses is thrown)TooManyNestedClausescovers "total clauses cumulatively in all of its children"; since 9.0 the checks "apply to all queries".- LUCENE-8811 made the count cumulative so that splitting clauses cannot bypass it; SHOULD and MUST_NOT clauses count too.
- The by-design history (a matching-only
visit()) is about term extraction and highlighting, not the limit; a counting-only fix keeps that view.
The full argument265 words
- Contract.
IndexSearcher.java:1193-1199: "Thrown when a client attempts to execute a Query that has more than getMaxClauseCount() total clauses cumulatively in all of its children". CHANGES.txt:4681-4684 (9.0.0, LUCENE-8811): "The checks are now implemented using a QueryVisitor and apply to all queries, rather than only booleans." MIGRATE.md:1531-1534: "IndexSearcher now performs max clause count checks on all types of queries".BooleanQuery.java:75-76: "We do the final deep check for max clauses count limit during IndexSearcher.rewrite".Query.java:88-93:visitshould "Recurse through the query tree, visiting any child queries." The boost query is part of the query'sequals,hashCodeandtoString. - Intent. On LUCENE-8811 (#9855) jpountz wrote "I like that it removes ways to by-pass the maximum clause count by splitting boolean queries into multiple sub boolean queries" and that the limit is "now actually the maximum number of clauses across the entire query"; romseygeek: "we also want to handle nested booleans, booleans wrapped in other queries, etc, in which case a QueryVisitor makes most sense".
- Role does not matter. The check visitor descends into every occur, MUST_NOT included (
IndexSearcher.java:903); executed controlsBooleanQuery(MUST 768, SHOULD 768)andBooleanQuery(MUST 768, MUST_NOT 768)are both rejected. - History.
BoostingQuery, whichboostByQueryreplaced, rewrote its context query as part of the tree; the query became hidden when it moved behind the values source. - Precedent. The same kind of gap was fixed as a bug elsewhere: LUCENE-8849 "DocValuesRewriteMethod.visit wasn't visiting its embedded query" (8.6.0) and LUCENE-9050 (8.3.1). We found no javadoc, test, CHANGES entry or thread that excludes values-source queries;
TestMaxClauseLimitcovers only Boolean, DisjunctionMax and phrase shapes.
The devil's-advocate check224 words
The strongest case (the intended-behaviour verifier): the four visit() bodies are a deliberate matching-only view, ported from Weight#extractTerms by LUCENE-3041, which already skipped the values source; LUCENE-8099 made the highlighters extract only getWrappedQuery(); on #12938 romseygeek wrote "FunctionQuery doesn't actually match things, it just scores, so it shouldn't be used for highlighting"; LUCENE-4261 (#5331, 2012) was closed as not a bug because value sources are opaque to the Scorer API; setMaxClauseCount says "per Query"; each embedded query is still checked alone; and visiting the source as ordinary clauses made Monitor miss two stored queries.
Why it fails: every item concerns term extraction, highlighting or Scorer navigation, and each predates or is independent of the cumulative check; none says values-source queries are outside the clause limit. "Per Query" was written during LUCENE-8811 to mean the entire query. FunctionMatchQuery and FunctionRangeQuery decide matching from the embedded query, so "it just scores" does not cover them. And the matching view need not change: the counting-only fix keeps termCollector and Monitor as they are. Still, the behaviour is inconsistent with the cumulative-limit contract rather than an explicit violation: neither Query.visit nor TooManyNestedClauses says outright that a query reachable only through a values source is a child, and maintainers may regard values sources as a deliberately opaque boundary or prefer a javadoc clarification, so the confidence is medium.
Recommended A visitor channel that only the clause check follows (validated)
Add a visitor channel that only the clause check follows: QueryVisitor.getValuesSourceVisitor(parent) returns an empty visitor by default and the check's visitor returns itself; values sources get a no-op visit hook that query-holding sources forward; the four function queries visit their source through it. The repro passes, and term extraction, highlighting and Monitor see nothing new.
// QueryVisitor: a channel that only accounting visitors follow
public QueryVisitor getValuesSourceVisitor(Query parent) {
return EMPTY_VISITOR;
}
// IndexSearcher.getNumClausesCheckVisitor(): count these clauses too
@Override
public QueryVisitor getValuesSourceVisitor(Query parent) {
return this;
}
// DoubleValuesSource and ValueSource: a no-op hook; query-holding sources forward it
public void visit(QueryVisitor visitor) {}
// FunctionScoreQuery.visit (likewise FunctionMatchQuery, FunctionRangeQuery, FunctionQuery)
in.visit(visitor.getSubVisitor(BooleanClause.Occur.MUST, this));
source.visit(visitor.getValuesSourceVisitor(this));All options and trade-offs4 options · 794 words
One option was built and validated; the others come from the hunt's verifiers. The requirement (adjudication, intended-behaviour verifier, tie-breaker): count the embedded query in the clause check without changing what matching-oriented visitors (termCollector, Monitor's query analyzer, the highlighters) see.
QueryVisitor gets getValuesSourceVisitor(Query parent), returning EMPTY_VISITOR by default; the clause-check visitor in IndexSearcher.getNumClausesCheckVisitor() overrides it to return itself. DoubleValuesSource and ValueSource get a no-op visit(QueryVisitor) hook; DoubleValuesSource.fromQuery's source and QueryValueSource forward it to their query, and the two sources boostByQuery builds and the two ValueSource/DoubleValuesSource bridges forward it to the source they wrap. FunctionScoreQuery, FunctionMatchQuery, FunctionRangeQuery and FunctionQuery add source.visit(visitor.getValuesSourceVisitor(this)) to visit (9 files, +74 lines, no deletions). The method name is a placeholder: ask maintainers which channel they prefer.
// QueryVisitor: a channel that only accounting visitors follow
public QueryVisitor getValuesSourceVisitor(Query parent) {
return EMPTY_VISITOR;
}
// IndexSearcher.getNumClausesCheckVisitor(): count these clauses too
@Override
public QueryVisitor getValuesSourceVisitor(Query parent) {
return this;
}
// DoubleValuesSource and ValueSource: a no-op hook; query-holding sources forward it
public void visit(QueryVisitor visitor) {}
// FunctionScoreQuery.visit (likewise FunctionMatchQuery, FunctionRangeQuery, FunctionQuery)
in.visit(visitor.getSubVisitor(BooleanClause.Occur.MUST, this));
source.visit(visitor.getValuesSourceVisitor(this));Trade-offs
- API addition on three public abstract classes (
QueryVisitor,DoubleValuesSource,ValueSource); every default is a no-op, so existing subclasses compile and behave as before. - Composite sources stay hidden until they forward the hook:
ValueSourcefunctions that wrap other sources (sum, product, if, ...), expression bindings in lucene/expressions, custom sources. The patch covers every source that holds a query directly and the wrappersboostByQueryuses; the rest is a follow-up. - Non-scoring searches now count the boost query too, although
FunctionScoreQuerydoes not run it there: an over-count in the same direction asIndexOrDocValuesQuerytoday (#14756). - Nothing is relaxed: each embedded query is still also checked on its own when it is rewritten.
- Behaviour change: queries accepted today whose total, counted with their embedded queries, is over the limit start to throw
TooManyNestedClauses. That is the point of the fix, but callers who rely on today's acceptance need a CHANGES/MIGRATE note; with the API addition this makes option A a design and compatibility question for maintainers, not an obvious one-line bug fix. - Optional:
RamUsageEstimator's query visitor (query-cache sizing) could follow the same channel; not done, not part of this report.
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; the worktree was removed afterwards):
- stock: the minimal test fails with assertions on and off; with the fix it passes with assertions on and off;
- closest suites:
org.apache.lucene.queries.function.*(14 classes, 111 tests, 0 failures) and coreTestMaxClauseLimit,TestQueryVisitor,TestDoubleValuesSource,TestIndexSearcher,TestBooleanQuery(62 tests, 0 failures, 1 skipped); all of coreorg.apache.lucene.search.*(214 classes, 2,339 tests, 0 failures, 49 skipped); - whole modules: queries (512 tests, 0 failures, 5 skipped), highlighter (722 tests), monitor (181 tests, 1 skipped), expressions (170 tests), all 0 failures;
- matching views unchanged: the intended-behaviour verifier's Monitor and
termCollectorprobe passes 4 of 4 with the fix, as on stock (Monitor still matches the storedboostByQueryandFunctionQuery(QueryValueSource)queries;termCollectorstill returns only[f:a]); - with the fix shadowed in the standalone runner (assertions off): the finder's 5 tests and the tie-breaker's 7 (including nesting) pass; an independent reproducer's 10 tests have 1 failure, its own
termCollectorassertion, which this fix deliberately leaves unchanged; checkGoogleJavaFormat,ecjLintMain,forbiddenApisMainandrenderJavadocpass for core and queries.
Forward the hook through getSubVisitor(SHOULD or FILTER, this), as the finder's first fix did.
Trade-offs
It makes the repro pass but changes what matching tools see: Monitor stops matching a stored boostByQuery(f:a, f:<rare>) and a stored FunctionQuery(QueryValueSource(...)) for a document with only f:a, and termCollector returns [f:a, f:b] instead of [f:a] (3 of 4 probe tests fail). That undoes the deliberate matching-only view (LUCENE-8099, #12938).
The tie-breaker's experiment: no new QueryVisitor method, because the clause check already descends into MUST_NOT and matching visitors do not. It passes the repros, the Monitor probe, 88 function-query and 70 highlighter tests.
Trade-offs
It labels a scoring query as prohibited: a visitor that also collects prohibited terms (the highlighter's QueryTermExtractor.getTerms(query, true)) would start returning the boost query's terms as if they came from a MUST_NOT clause. It only shows that a counting-only path exists.
Say in setMaxClauseCount and TooManyNestedClauses that queries held by values sources are checked on their own, not in the cumulative total.
Trade-offs
Cheapest, no API change, but it leaves the bypass, including the exponential growth with nesting, and goes against LUCENE-8811's aim of no bypass by splitting clauses.
Products that turn user input into function queries with embedded sub-queries (for example a query() function) and rely on maxClauseCount alone to bound query size should bound each embedded query themselves.
A hunt unit on queries that run an embedded query but hide it from QueryVisitor compared the clause limit's count with the clauses that actually run, for 16 wrapper queries (1,440 bounded-exhaustive checks), and scanned the bytecode for query- and values-source-typed fields that visit never reads. The five function-query sites under-counted; a public-API JUnit then confirmed it.
The method in full193 words
Method: semi-formal (the hunt's tier for bounded-exhaustive checks against a reference model). The unit's lens was queries that execute an embedded query but hide it from QueryVisitor.
- Differential check. On the stock main jars, 16 wrapper sites x primary leaves {1, 2, 4} x hidden leaves {1..10} x copies {1, 2, 3} = 1,440 checks at
maxClauseCount8 compared the limit's decision with a count of the clauses that really execute, with assertions on and off.boostByQuery,FunctionScoreQuery(fromQuery),FunctionMatchQuery(fromQuery),FunctionQuery(QueryValueSource)andFunctionRangeQuery(QueryValueSource)under-counted in 44, 44, 35, 35 and 35 of their 90 checks; theBooleanQuery,ConstantScoreQueryandDisjunctionMaxQuerycontrols had 0 mismatches in 270, and the oracle caught the knownIndexOrDocValuesQueryover-count (52), so it is not vacuous. - Bytecode scan. The JDK ClassFile API listed query- and values-source-typed fields that
visit()never reads (64 such fields in 142 query classes). The four function-query fields are among them, each read when the weight is built:FunctionScoreQuery.sourceandFunctionMatchQuery.sourcebycreateWeight,FunctionQuery.funcandFunctionRangeQuery.valueSourceby their weight constructors. - JUnit. The mismatch was reduced to a public-API test against a
BooleanQuerycontrol, then minimized to the one-test repro above.
No prior report: a follow-up search on 2026-10-28 (GitHub, the JIRA archive, mailing lists, downstream code, the web, all 413 open PRs) and a GitHub refresh on 2026-10-31. Related, not this bug: LUCENE-4261 (#5331, value sources opaque to Scorer navigation) and a #15097 remark on query-cache RAM sizing.
Searches3 searches
- LUCENE-4261 (#5331, 2012). A user asked for
FunctionQuery's nestedQueryValueSourcescorers throughScorer.getChildren(); it was closed as not a bug ("whatever that function does is opaque really"). A different API and consequence, years beforeQueryVisitorand the cumulative check. - #15097 (comment of 2025-08-22). A clause-counting visitor would not see a large
DoubleValuesSourceheld by aFunctionScoreQuery. Its subject is query-cache RAM sizing; it mentions neither an embedded query nor the clause limit. - Support: LUCENE-8811 (#9855); #14756 and open PR #16175 (the opposite over-count of
IndexOrDocValuesQuery; "for the purpose of visiting, we should visit all variants"); #14759 ("QueryVisitor is supposed to be a query introspection API"); LUCENE-8849 and LUCENE-9050 (the same kind ofvisit()gap, fixed as bugs). - Limits: GitHub search is keyword-based (the open-PR file scan finds fixes whatever their wording); two queries of the 2026-10-31 refresh hit the rate limit in the scripted re-run and are recorded from the interactive run two minutes earlier.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream Elasticsearch, OpenSearch and Solr | 2026-10-28 | the scripted prior-report search, two specs (118 queries: clause-limit terms; term-extraction, highlighting and sizeOf terms), 36 manual GitHub searches, and the changed-file lists of all 413 open PRs | no report; 28 open PRs match the file filter, none changes these visit() bodies |
| JIRA archive (LUCENE, SOLR) and mailing lists (dev@, java-user@, issues@, commits@) | 2026-10-28 | 14 JQL searches, 18 list searches; threads read in full: #5331, #15097, #15124, #12938, #13165, #9147, #9701, #8854, #10801, #14756, #9855, LUCENE-3041 | related discussions only |
| GitHub issues and PRs updated since 2026-10-28 | 2026-10-31 | 10 searches (QueryVisitor, DoubleValuesSource, ValueSource visit, TooManyNestedClauses, clause count, maxClauseCount, FunctionMatchQuery, QueryValueSource, FunctionScoreQuery, boostByQuery) and the list of all 72 updated items | 0 hits; none of the 72 items concerns this (that list is from an interactive run two minutes before the scripted one and was checked by title) |
Introduced in 9.0.0 by LUCENE-8811 (53f56fb7ad4, 2019), which turned the clause limit into one cumulative QueryVisitor walk; the visit() bodies date from 8.1.0 (LUCENE-3041) and were harmless before. Every release 9.0.0 to 10.6.0 and main is affected; 8.x is not.
Versions tested8 rows
Introduced by 53f56fb7ad4 (LUCENE-8811 "Move max clause checks to IndexSearcher", now #9855; a JIRA patch committed by romseygeek on 2019-06-26). It added query.visit(getNumClausesCheckVisitor()) to IndexSearcher.rewrite (IndexSearcher.java:878 on main, by git blame). Its 8.x backport fa3bf88783a was reverted before 8.2.0 (2885a115782, 193c77fe1a0), so it first shipped in 9.0.0.
The fix site is fbd05167f45 (LUCENE-3041, the QueryVisitor API, 2019-03-14), which wrote the four visit() bodies on main as ports of Weight#extractTerms; they have not changed since. 8.1.0 got the same bodies through the separate branch_8x backport ecf86ecab7a. Adding only the LUCENE-8811 check to 8.11.4 brings the bug back (commit isolation).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone runner); passes with the fix |
| 10.6.0 | affected | minimal test fails (release jars, assertions off) |
| 9.12.3, 10.5.2 | affected | minimal test fails (release jars, assertions off) |
| 9.0.0 to 10.5.2 (33 releases) | affected | history probe: the nested BooleanQuery controls are rejected, 5 of 5 function-query cases accepted |
| 8.1.0, 8.2.0, 8.11.4 | latent | no cumulative check: the nested controls are accepted too |
| 8.11.4 + only the LUCENE-8811 check | isolation | controls rejected, 5 of 5 function-query cases accepted, as on 9.0.0 |
upstream main 2f424fd428d, branch_11x | read | fix-site files identical to 645d4fe14a20 |
branch_10_6 7e5aec4370e | read | 4 of the 9 fix-site files differ in unrelated code; the bug's code paths are the same and the fix diff applies (not built) |
Confirmed by a tie-breaker after the adjudication called it disputed: an independent reproducer and a reviewer from another model family (OpenAI GPT-5.6) said real, the intended-behaviour verifier said uncertain. A credibility audit, the minimization and this doc's gradle run re-ran it; the only "not real" was procedural, from a read-only review.
Checks and verdicts380 words
The cross-model reviews could only read code; every execution result comes from the finder, the independent reproducer, the intended-behaviour verifier, the adjudication, the tie-breaker, the audit, the minimization and this doc.
| Check | Verdict | What it found |
|---|---|---|
| Finder (bounded-exhaustive differential check, then JUnit) | candidate | 1,440 checks: the five function-query sites under-count in 44, 44, 35, 35 and 35 of 90; controls 0 of 270. Its 5-test JUnit fails 5 of 5 on main, branch_10_6, 10.5.2 and 9.12.3. |
| Refute review (OpenAI GPT-5.6, read-only) | not real (procedural, low) | Could not run commands and voted not real on procedure only then; named no flaw in the repro or the root cause. A separate re-run fails 4 of 4. |
| Independent reproducer | real | Own 10-test JUnit (768 + 768 clauses; 32 x boostByQuery, 32,032 clauses): 7 fail on main and 10.5.2 (assertions on and off) and 9.12.3; 3 controls pass. |
| Works-as-designed lens (OpenAI GPT-5.6, read-only) | real (low) | Root cause agrees, inputs in contract; the by-design case is moderate but has no written support. Re-runs fail on main, 10.5.2 and 9.12.3. |
| Intended-behaviour devil's advocate | uncertain | Built the by-design case (extractTerms port, highlighting history, "per Query") and showed that visiting the source as ordinary clauses breaks Monitor (3 of 4 probe tests). |
| Adjudication | disputed (medium) | Re-ran the independent reproduction's JUnit on main (10 run, 7 failed, assertions on and off) and sent three questions to a tie-breaker. |
| Tie-breaker | confirmed (medium) | Own probe: 3 of 7 fail on main, 10.5.2 and 9.12.3 with 4 controls passing (SHOULD and MUST_NOT clauses are counted; nesting 20, 84, 340 leaf queries at a limit of 4). Showed by execution that a counting-only path exists. |
| Credibility audit | keep (minor) | Re-ran the finder's JUnit with a new seed (5 of 5 fail) and the independent reproduction's (7 of 10 fail) on main. |
| Minimal repro | verified | One test; fails on 645d4fe14a20 with assertions on and off and two seeds. |
| This doc (gradle and release jars) | confirmed; fix validated | Stock fails through gradle with assertions on and off; the recommended fix passes, with the closest suites, core search.*, four modules and lint; the Monitor probe is unchanged. The test also fails on the 9.12.3, 10.5.2 and 10.6.0 jars. |
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 499 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,876 words
- Registry title
- IndexSearcher's cumulative maxClauseCount check never counts a query held by a values source (FunctionScoreQuery.boostByQuery's boost query, DoubleValuesSource.fromQuery, QueryValueSource): the function queries' visit() stops at the source and each embedded query is checked alone with a fresh budget, so the total number of executed clauses is unbounded
- Area
- queries / function queries (FunctionScoreQuery, FunctionMatchQuery, FunctionRangeQuery and FunctionQuery QueryVisitor traversal of DoubleValuesSource/ValueSource; consumer: IndexSearcher's cumulative clause limit)
- Priority
- Low, rank 733: Function queries hide their inner query from the cumulative maxClauseCount check, so the limit can be bypassed without bound while hits stay correct, and it is the strongest of the new QueryVisitor-hiding rows, so it leads them after LFV-402 and above the 10.6.0 regression LFV-910.
- Impact class
- minor
- Impact note
- Applications that build FunctionScoreQuery.boostByQuery (the documented replacement for the removed BoostingQuery), a FunctionScoreQuery or FunctionMatchQuery over DoubleValuesSource.fromQuery, or the @lucene.experimental FunctionQuery/FunctionRangeQuery over QueryValueSource, with the default searcher and the default maxClauseCount (1024), get no TooManyNestedClauses for the embedded query's share of the clauses: each embedded query is checked only on its own, so one function query can run about twice the limit and repetition or nesting multiplies it (measured: 32 boostByQuery clauses with 1,000-clause boost queries run 32,032 term clauses and 100 of them run 100,100, all accepted; at maxClauseCount 4, nesting boostByQuery 1, 2 and 3 levels deep pulls scorers for 20, 84 and 340 leaf queries, all accepted); FunctionScoreQuery runs its boost query only on scoring searches, while FunctionMatchQuery and FunctionRangeQuery always run the embedded query and it decides which documents match; hits and scores are correct, ordinary scale, every release since 9.0.0.
- Summary
- You search FunctionScoreQuery.boostByQuery(in, boostMatch, 2f), where in and boostMatch are each a 600-term disjunction (1,200 term clauses that all run), with the default maxClauseCount of 1024. You expect TooManyNestedClauses, which you do get for a BooleanQuery holding the same clauses (even when half of them are scoring-only SHOULD or MUST_NOT clauses), because the check counts 'total clauses cumulatively in all of its children' and LUCENE-8811 made it cumulative so that splitting clauses over sub-queries no longer bypasses it. Instead the search runs with correct hits: FunctionScoreQuery.visit visits only
in, FunctionMatchQuery, FunctionRangeQuery and FunctionQuery.visit call visitLeaf(this), DoubleValuesSource and ValueSource have no visit hook, and the embedded query is rewritten later with searcher.rewrite, which checks it alone with a fresh count; FunctionScoreQuery(fromQuery), FunctionMatchQuery(fromQuery) and FunctionQuery/FunctionRangeQuery over QueryValueSource behave the same. - Introduced
- 53f56fb7ad44afab9c78fab2e89737438093247f (LUCENE-8811 'Move max clause checks to IndexSearcher', now apache/lucene#9855, a JIRA patch committed by GitHub romseygeek, no GitHub PR), first released in 9.0.0: it added the cumulative check query.visit(getNumClausesCheckVisitor()) to IndexSearcher.rewrite (IndexSearcher.java:878 on main), which never reaches a query held by a values source; its branch_8x backport fa3bf88783a was reverted by 2885a115782 (branch_8x) and 193c77fe1a0 (branch_8_2) on 2019-07-15, before 8.2.0. The fix site is older and was harmless before: fbd05167f455e3ce2b2ead50336e2b9c2521cd6c (LUCENE-3041 'QueryVisitor', apache/lucene-solr#581, by GitHub romseygeek; branch_8x ecf86ecab7a, released in 8.1.0) wrote the four visit() bodies as ports of Weight#extractTerms, which already skipped the values source [read: git blame and git log -L list only fbd05167f45 for the four visit() bodies and 53f56fb7ad4 for IndexSearcher.java:878; the 107 review comments of apache/lucene-solr#581 never discuss values sources; exec: commit isolation, 8.11.4 plus only the LUCENE-8811 check in a shadow IndexSearcher rejects the nested BooleanQuery controls and accepts 5 of 5 function-query cases, as 9.0.0 does]; not a regression of a check that once worked, so no bisect (2019-06-26 (author date of 53f56fb7ad4, LUCENE-8811, the cumulative check that made the hidden query matter); 2019-03-14 (author date of fbd05167f45, LUCENE-3041, the visit() bodies))
- First affected version
- 9.0.0 [exec] (the first release with the cumulative check: the history probe's nested BooleanQuery controls are rejected while 5 of 5 function-query cases are accepted, and the finder's JUnit (9.0.0 import variant) fails 5 of 5, -da); the visit() bodies exist since 8.1.0 but are latent in 8.x, which has no cumulative check [exec: on 8.1.0, 8.2.0 and 8.11.4 the nested controls are accepted too]; the JUnit's API (TooManyNestedClauses, IndexSearcher.getMaxClauseCount) is absent in 8.11.4 [exec: compile error]
- Affected versions
- All 33 releases 9.0.0 to 10.5.2 on Maven Central, plus 10.6.0 (the branch_10_6 head 4ae946e5a9b, the RC1 revision) and main a9779ef3c193 [exec: the follow-up's history probe (public API from 8.1 on) fails, 5 of 5 function cases accepted with both controls rejected, on every release 9.0.0 to 10.5.2 (-da) and on main (-ea); the finder's 5-test JUnit fails 5 of 5 on 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 (-da), branch_10_6 (-da) and main (-ea and -da); the independent reproduction's 10-test JUnit fails 7 of 10, with its 3 controls passing, on main and 10.5.2 (each -ea and -da) and 9.12.3 (-da); the tie-breaker's 7-test probe fails 3 of 7, with its 4 controls passing, on main (-ea and -da), 10.5.2 and 9.12.3 (-da)]; latent, not affected, in 8.1.0 to 8.11.4 (no cumulative check) [exec: 8.1.0, 8.2.0, 8.11.4]; upstream main c5ab3e57b9b (author date 2026-10-28) still has the same visit() bodies: the last upstream commits on the fix-site files are #16697 (DoubleValuesSource.java, author date 2026-10-09), #16431 (FunctionScoreQuery.java, 2026-08-22), #15406 (ValueSource.java, 2025-12-02), #13440 (FunctionMatchQuery.java, FunctionRangeQuery.java, FunctionQuery.java, 2024-06-06) and LUCENE-9570 (QueryValueSource.java, 2020-12-28), none touching 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; also reached by exhaustive check
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-28, read-only: a scripted prior-report search with two specs, v1 on clause-limit terms (16 GitHub issue/PR, 3 commit, 2 downstream Elasticsearch/OpenSearch/Solr, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 12 web queries; 240 hits, 0 high) and v2 on term-extraction, highlighting and sizeOf terms (61 queries, 2,834 hits, mostly extractTerms history noise); 36 manual GitHub searches, 14 JQL searches over LUCENE and SOLR and 18 mailing-list searches; threads read in full: #5331, #15097, #15124, #12938, #13165, #9147, #9701, #8854, #10801, #14756, #9855 and the LUCENE-3041 JIRA comments, with the 107 review comments of apache/lucene-solr#581 grepped; a changed-file scan of all 413 open apache/lucene PRs (28 match the file filter; the 20 that change main-source files have no visit change in these classes) and of the 69 PRs updated since 2026-10-26), plus the finder's run, the independent reproduction's, the intended-behaviour check's and the tie-breaker's own searches. Refresh 2026-10-28: GitHub search 'FunctionScoreQuery visit' (only #15097, #10801), 'boostByQuery' (#9404, #10801, #9147), 'DoubleValuesSource QueryVisitor' (0), 'QueryValueSource' (9 old items incl. #5331, none on the clause limit), 'FunctionMatchQuery' (7 old items), 'maxClauseCount' and 'QueryVisitor' updated since 2026-10-26 (0), 'visit' updated since 2026-10-27 (two HNSW PRs); of the 40 PRs updated since 2026-10-27 (newest #16931), a changed-file scan finds only #14673 touching FunctionScoreQuery.java and IndexSearcher.java ('Merge 10.1 into 9.11', a 445-file branch-merge PR, closed unmerged, with no visit or clause-check change in its diff). Related, not this bug: LUCENE-4261 (#5331, 2012, closed as not a bug: a maintainer said FunctionQuery is a leaf for the Scorer API and value sources are 'opaque'; a different API, years before QueryVisitor and the cumulative check, and the strongest design-intent argument); #15097 (a 2025-08-22 comment notes that a clause-counting visitor would not catch a FunctionScoreQuery holding a large DoubleValuesSource, in the context of query-cache RAM sizing, with no mention of an embedded Query or the clause limit); #15124 (LRUQueryCache sizes keys with RamUsageEstimator.sizeOf); #12938/#13165 (FunctionQuery treated as score-only for highlighting); LUCENE-8811 (#9855, origin of the cumulative check, 'removes ways to by-pass the maximum clause count'); #14756 and open PR #16175 (the opposite over-count of IndexOrDocValuesQuery; 'for the purpose of visiting, we should visit all variants'); #14759 ('QueryVisitor is supposed to be a query introspection API'); LUCENE-8849 (#9892), LUCENE-9050 (#10092) and LUCENE-9005, the same kind of visit() omission fixed as bugs in other classes; #9147 (LUCENE-8099, boostByQuery replaced BoostingQuery). Gaps: GitHub search is keyword-based (the open-PR file scan finds fixes regardless of wording) and may not index line-review comments; several web searches were throttled and retried until all completed.
- 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 (seeds DEADBEEF and 1234ABCD); with the recommended fix it passes, together with the closest suites and the queries, highlighter, monitor and expressions modules. Release jars 9.12.3, 10.5.2 and 10.6.0: the minimal test fails (assertions off). Earlier, on maina9779ef3c193(same fix-site code): every release 9.0.0 to 10.5.2 affected, 8.1.0 to 8.11.4 not. Upstream main2f424fd428d(12.0.0-SNAPSHOT) andbranch_11x603af870375have the same fix-site files; onbranch_10_67e5aec4370eand the 10.6.0 tag four of the nine differ in unrelated code, while the bug's code paths are the same and the fix diff applies (read, refs of 2026-10-29/30).
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:101-106 (boostByQuery wraps boostMatch in DoubleValuesSource.fromQuery), :121 (createWeight: source.rewrite(searcher), only when scores are needed), :124-131 (rewrite rewrites only `in`), :133-136 (visit: in.visit(visitor.getSubVisitor(MUST, this))); FunctionMatchQuery.java:80-83 (visit: visitLeaf), :88 (source.rewrite); FunctionRangeQuery.java:132-135 (visit: visitLeaf), :42 (@lucene.experimental); FunctionQuery.java:92-95 (visit: visitLeaf), :38 (@lucene.experimental); valuesource/QueryValueSource.java:82-86 (createWeight: searcher.rewrite(q)); lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:741 (fromQuery), :776-780 (QueryDoubleValuesSource.rewrite: searcher.rewrite(query)); IndexSearcher.java:255 (setMaxClauseCount 'per Query'), :871-880 (rewrite runs getNumClausesCheckVisitor at :878), :896-932 (the visitor; :902-903 'Return this instance even for MUST_NOT'), :1193-1199 (TooManyNestedClauses 'total clauses cumulatively in all of its children'); Query.java:88-93 ('Recurse through the query tree, visiting any child queries'); lucene/CHANGES.txt:4664-4667 (LUCENE-8811 'apply to all queries'); lucene/MIGRATE.md:1533https://github.com/apache/lucene/issues/15097#issuecomment-3212567936 (sgup432, 2025-08-22T00:31:39Z; read via gh api 2026-10-28): 'if a BooleanQuery has FunctionScoreQuery as one of the clause, FunctionScoreQuery holds DoubleValuesSource where a user can erroneously a huge sized source ... the number of nested or child clauses may be small, but the overall query size can still be large' (query-cache sizing context)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:587 (at 645d4fe14a20) (:601), :623-624, :750-756 (:753), :871-880 (:878), :882-889 (:884), :891-932 (:902-905; the tests before the increments :909-912, :917-920, :926-929), :1193-1199; call path also (IndexSearcher.rewrite:884 <- IndexSearcher.search:753 <- IndexSearcher.searchAfter:601 <- IndexSearcher.search:624)lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:101-106, (at 645d4fe14a20) :109-122 (:118-121), :124-131, :133-136, :315-317, :394-396lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:80-83, (at 645d4fe14a20) :88; FunctionRangeQuery.java:132-135, :149; FunctionQuery.java:71, :92-95; valuesource/QueryValueSource.java:82-86lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:741-743, (at 645d4fe14a20) :776-780 (:779); grep: no visit method in DoubleValuesSource.java or lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java
- Who is affected
lucene/MIGRATE.md:1377-1381 (at 645d4fe14a20) (BoostingQuery replaced by boostByQuery); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:38, FunctionRangeQuery.java:42 (@lucene.experimental); lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:75-86lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:118-120 (at 645d4fe14a20) (non-scoring searches return the inner weight)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:255, (at 645d4fe14a20) :903, :1193-1199; lucene/CHANGES.txt:4681-4684, :5800 (LUCENE-8849, 8.6.0 Bug Fixes), :6163-6164 (LUCENE-9050, 8.3.1 Bug Fixes); lucene/MIGRATE.md:1531-1534; lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:75-76; lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:753-764, :782-785lucene/core/src/test/org/apache/lucene/search/TestMaxClauseLimit.java (at 645d4fe14a20) (test methods at :30-150)
- Suggested fix
lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryTermExtractor.java:144-154 (at 645d4fe14a20) (MUST_NOT sub-visitor kept when includeProhibited)Lucene main 645d4fe14a20: grep finds no getValuesSourceVisitor anywhere in lucene/ (the name is free)
- TestBoostByQueryClauseLimit.java (one test):
repro/LFV-923/src/org/apache/lucene/queries/function/TestBoostByQueryClauseLimit.java(60 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an executed probe on the stock main jars (the two clause checks and their counts, the scores, the BooleanQuery control).
assets/LFV-923/bug.png(499 KB)
