CompositeVerifyQuery finds candidates with a cheap index query and checks each one against a shape. A tool that walks the query with Query#visit never sees that index query.
- CompositeVerifyQuery (spatial-extras, experimental) pairs an index query, a cheap approximation, with a shape predicate that verifies each candidate. A document is a hit only if it matches both.
- Query#visit walks the query tree. A wrapper passes the visitor on to its child queries; a leaf reports its terms (
consumeTerms) or, if it matches on no terms, callsvisitLeaf. - Two users of visit:
QueryVisitor.termCollector(term extraction, for example for highlighting) andIndexSearcher's clause limit, which since 9.0 counts every leaf of the whole tree againstmaxClauseCount(1024 by default) and throwsTooManyNestedClausespast it.
body:a and checks each candidate against the shape, but visit() stops at the wrapper, so term collection finds [] and the clause limit counts one clause. Select the still to open it at full size. Generated with AI, checked against the code.- Wrap
TermQuery(body:a)innew CompositeVerifyQuery(indexQuery, shapePredicate)and collect its terms withQueryVisitor.termCollector(terms). - You expect
[body:a]: every hit must match the index query, andQuery#visitis documented to "visit any child queries".ConstantScoreQuery(TermQuery(body:a))gives[body:a]. - You get
[]:visit()only callsvisitor.visitLeaf(this), and the term collector ignores leaves. - Same line, second effect (9.0 on): with
maxClauseCount = 8, a 20-clauseDisjunctionMaxQueryalone is rejected withTooManyNestedClauses, but wrapped as the index query it passesIndexSearcher#rewriteand runs, counted as 1 clause. - Hits are correct in every run. One line fixes both effects: visit the index query through a
FILTERsub-visitor, asConstantScoreQuerydoes. Then a wrapped index query over the limit, which runs today, throwsTooManyNestedClauses.
Code that builds CompositeVerifyQuery itself around a term-bearing or large index query, then extracts terms through Query#visit or relies on maxClauseCount to reject oversized queries. In-tree, CompositeSpatialStrategy loses at most one binary cell token (points-only prefix tree, IsEqualTo or IsWithin) and its clause count is unchanged. No run shows a wrong hit or count.
Mechanism, terms and numbers662 words
CompositeVerifyQuery (spatial-extras, @lucene.experimental, public constructor) runs an index query as the approximation of every match and verifies each candidate against a shape predicate in TwoPhaseIterator#matches(). Its rewrite rewrites the index query and keeps it in the tree, and its createWeight runs it, so every hit must match it. But visit is visitor.visitLeaf(this); and never visits the index query.
Two consumers go wrong. QueryVisitor.termCollector and other visit-based term extraction (Lucene's UnifiedHighlighter extracts its terms this way) see none of the index query's terms: every release since 8.1.0. IndexSearcher's cumulative clause check, built on Query#visit since 9.0.0, counts the whole wrapper as one clause, so an index query with more clauses than maxClauseCount runs instead of throwing TooManyNestedClauses. No run shows a wrong hit or count, and matching does not use visit. In-tree, CompositeSpatialStrategy loses at most one binary cell token (points-only prefix tree, IsEqualTo or IsWithin on a point) and never changes the clause count. The fix is one line, as in ConstantScoreQuery; it changes admission, since some wrapped queries that run today then throw TooManyNestedClauses.
- The index query is a real child.
CompositeVerifyQuery.java:41private final Query indexQuery; // approximation (matches more than needed);:44-47the public constructor accepts anyQuery. - It is rewritten and kept.
:49-56:rewritecallsindexQuery.rewrite(indexSearcher); if that returns a different query it returns a newCompositeVerifyQueryaround it, otherwisesuper.rewrite(indexSearcher), the query itself. Either way the index query stays in the tree. - It is executed as the approximation.
:92-118:createWeightcallsindexQuery.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, boost)directly (:95-97), takes its scorer per segment (:102; no scorer, no hits) and wraps that iterator in the predicate'sTwoPhaseIterator(:107-108). A document the index query does not match is never a hit (CompositeVerifyQuery(TermQuery(body:t2), P), with a predicate all 4 documents satisfy, returns 1 of 4 documents). visithides it.:87-90:visitor.visitLeaf(this);.- Term extraction sees nothing.
QueryVisitor.java:91-98:termCollectoroverrides onlyconsumeTerms;visitLeafis a no-op by default (:58).UnifiedHighlighter.extractTermsisquery.visit(QueryVisitor.termCollector(queryTerms))(UnifiedHighlighter.java:481-485; read, not run with this class). - The clause guard counts one.
IndexSearcher.java:871-880:rewriterewrites to a fixed point and then runsquery.visit(getNumClausesCheckVisitor())(:878); the counter (:896-931) adds one pervisitLeaf,consumeTermsorconsumeTermsMatchingcall (it tests the limit before counting,:909-912, so at a limit of 8 the 10th leaf throws: the separate off-by-one LUCENE-9299, #10339, which this bug and its fix do not touch). The wrapper is onevisitLeafcall whatever it holds, and the class never sends its index query throughIndexSearcher#rewrite, so the searcher's clause check never sees it. (AbstractKnnVectorQueryalso does not visit its filter, but rewrites it throughindexSearcher.rewrite(...),AbstractKnnVectorQuery.java:93, so its filter is still checked.) - What still limits a query. Each
BooleanQuery.Builder#addrejects more thanmaxClauseCountclauses in that one builder (BooleanQuery.java:74-86, "We do the final deep check ... during IndexSearcher.rewrite").DisjunctionMaxQueryhas no build-time check, and nested builders each stay under the limit. A query timeout still applies.
termCollectoroverCompositeVerifyQuery(TermQuery(body:a), predicate):[]; over the bareTermQueryand overConstantScoreQuery(TermQuery):[body:a].maxClauseCount = 8, index query =DisjunctionMaxQueryof 20TermQueryclauses:searcher.rewrite(dmq)throws;BooleanQuery(MUST CompositeVerifyQuery(TermQuery(body:t0), predicate), FILTER dmq)throws;searcher.rewrite(new CompositeVerifyQuery(dmq, predicate))does not. With aBooleanQueryof 4 MUST sub-queries of 5 SHOULD terms each, the wrapped query passes andcount()returns the correct 4.- In-tree:
CompositeSpatialStrategyover a points-only prefix tree (6 levels) and a serialized shape field,IsEqualToorIsWithinonPt(10, 20), buildsCompositeVerifyQuery(rpt:s5x1g8, shapeDocVal(sdv) Equals Pt(x=10.0,y=20.0))around aTermQueryon a cell token (RecursivePrefixTreeStrategy.java:216-229): 0 terms instead of 1; 1 leaf either way; the 1 hit is correct. Its other index queries (IntersectsPrefixTreeQueryand the like) visit as one leaf themselves, so recursing changes neither their terms nor their clause count.
Exposure and likelihood202 words
- Term extraction.
QueryVisitor.termCollector,UnifiedHighlighter's term extraction (read) or an application's own visitor (field-based term lists, query logging, cache keys) miss the index query's terms. Every release since 8.1.0. (The classicHighlighterskips everyorg.apache.lucene.spatial.*query anyway,WeightedSpanTermExtractor.java:266-276, LUCENE-7293.) - Clause guard. Since 9.0.0, an index query with more leaf clauses than
maxClauseCount(1024 by default), in aDisjunctionMaxQueryor spread over nested builders that each stay under the limit, runs instead of throwingTooManyNestedClauses. Per-builder checks and query timeouts still apply. - In-tree reach is small.
CompositeSpatialStrategyreaches only the term half, only with a points-only prefix tree and a non-optimized predicate (IsEqualTo,IsWithin; alsoIntersectsaftersetOptimizePredicates(false): 0 terms instead of 1, executed in the fact check). The lost term is one binary cell token on the spatial field, so the in-tree highlighting effect is small. - Default vs opt-in. Default searcher, codec and
maxClauseCountsuffice; using the class is opt-in. No scale is needed. - What does not happen. No run shows a wrong hit or count; the score is the wrapper's constant; no exception; no index change. The documents a query matches do not depend on
visit. Nobody has reported it since 8.1.0 (2019).
One JUnit test, one method, no index: wrap TermQuery(body:a) in a CompositeVerifyQuery and collect its terms with QueryVisitor.termCollector. Public API only; nothing random.
/*
* 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.spatial.composite;
import java.util.HashSet;
import java.util.Set;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.QueryVisitor;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.spatial.serialized.SerializedDVStrategy;
import org.apache.lucene.spatial.util.ShapeValuesPredicate;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
public class TestCompositeVerifyQueryVisit extends LuceneTestCase {
public void testTermCollectorSeesIndexQuery() {
SpatialContext ctx = SpatialContext.GEO;
ShapeValuesPredicate predicate =
new ShapeValuesPredicate(
new SerializedDVStrategy(ctx, "shape").makeShapeValueSource(),
SpatialOperation.Intersects,
ctx.getShapeFactory().rect(-10, 10, -10, 10));
Set<Term> terms = new HashSet<>();
new CompositeVerifyQuery(new TermQuery(new Term("body", "a")), predicate)
.visit(QueryVisitor.termCollector(terms));
// expected the index query's term [body:a]; actual [] (visit only calls visitLeaf(this))
assertEquals(Set.of(new Term("body", "a")), terms);
}
}
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestCompositeVerifyQueryVisit -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
termCollectorcollects[body:a], the index query's term.- Observed
- It collects
[]:AssertionError: expected:<[body:a]> but was:<[]>, with assertions on and off.
More files, environment, full output252 words
Query#visit needs no reader. The class sits in org.apache.lucene.spatial.composite only to be next to the class under test; it uses no package-private access. The predicate is the smallest valid ShapeValuesPredicate; visit never touches it. The seed only satisfies LuceneTestCase.
The clause-limit effect is a second symptom of the same line. The finder's longer test shows it (20-clause DisjunctionMaxQuery, maxClauseCount = 8; it fails 2 of 2 on 645d4fe14a20 with assertions on and off and on the 10.6.0 jars); it is evidence for the issue text, not part of this repro.
Regression test: this same test; it passes with fix A (and with B or C).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT) and the 10.6.0 release jars: fail; with fix A: passes. Term half affected since 8.1.0, clause-limit half since 9.0.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is plain Java) |
# $REPRO = the directory holding TestCompositeVerifyQueryVisit.java (this bug's repro-minimal/src/org/apache/lucene/spatial/composite/)
cp $REPRO/TestCompositeVerifyQueryVisit.java $LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/composite/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestCompositeVerifyQueryVisit -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:spatial-extras:test FAILED
TestCompositeVerifyQueryVisit > testTermCollectorSeesIndexQuery FAILED
java.lang.AssertionError: expected:<[body:a]> but was:<[]>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:987833AD403AD8D3]:0)
at org.apache.lucene.spatial.composite.TestCompositeVerifyQueryVisit.testTermCollectorSeesIndexQuery(TestCompositeVerifyQueryVisit.java:43)
# the finder's two-symptom test, 645d4fe14a20 (assertions on and off) and 10.6.0 jars (off)
testTermCollectorSeesIndexQueryTerms
=> java.lang.AssertionError: visit must recurse into the executed index query expected:<[body:a]> but was:<[]>
testClauseLimitCountsIndexQueryClauses
=> junit.framework.AssertionFailedError: the 20 executed index-query clauses must count toward maxClauseCount=8Query#visitmust "Recurse through the query tree, visiting any child queries";visitLeafis for queries "that do not match on terms".ConstantScoreQueryandIndexOrDocValuesQueryrecurse.- The class was written in 2015 to pass its index query's terms through; a mechanical API sweep dropped that before any release.
- The same omission elsewhere was fixed as a bug (LUCENE-8849).
The full argument266 words
- Contracts.
Query.java:89"Recurse through the query tree, visiting any child queries."QueryVisitor.java:54visitLeafis "Called by leaf queries that do not match on terms";:87termCollector"collects all terms that may match a query".IndexSearcher.java:1194-1195TooManyNestedClausesis "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 "apply to all queries, rather than only booleans";MIGRATE.md:1533: "on all types of queries". - The design rule. The
QueryVisitorauthor, in its review (lucene-solr#581, 2019-02-26): "Yes, all compound queries should pull a sub visitor and callvisiton each of their sub queries with it. AndvisitLeafis for queries that don't match on terms." - Peers recurse.
ConstantScoreQuery.java:77-79(FILTER),BoostQuery.java:105-107(MUST),IndexOrDocValuesQuery.java:141-145(MUST, both children),FunctionScoreQuery(MUST). - History. LUCENE-5579 (#6641,
489f56def84, 2015-04-10) created the class withextractTerms(terms) { indexQuery.extractTerms(terms); }. The LUCENE-6425 sweep (#7484,f4c05465673, 2015-04-16) movedextractTermstoWeightand dropped that delegation without a replacement, although its own no-opConstantScoreWeight.extractTermssays "override if your constant-score query does wrap terms"; both landed before 5.2.0. LUCENE-3041 (fbd05167f45, 2019) then wrotevisitLeaf(this); none of the 123 comments on lucene-solr#581 mentions this class. - Precedent. LUCENE-8849 (#9892, opened by the class's author; 8.6.0 Bug Fixes,
CHANGES.txt:5800): "DocValuesRewriteMethod.visit wasn't visiting its embedded query". LUCENE-9050 (#10092,CHANGES.txt:6163): "MultiTermIntervalsSource.visit() was not calling back to its visitor". - No test or doc says otherwise. No test in
lucene/spatial-extras/src/testcallsvisitortermCollector; no javadoc, CHANGES or MIGRATE entry calls the class opaque.
The devil's-advocate check258 words
The strongest case for "intended": the class is one atomic, experimental spatial predicate whose approximation is an implementation detail; the 8.1 CHANGES entry for LUCENE-3041 (CHANGES.txt:6469-6475) lets a query "either pass the visitor down to any child queries, or call a visitX() or consumeX() method on it"; Lucene has deliberately opaque composites (block-join queries, DrillSidewaysQuery); before 8.1 the class's extractTerms reported nothing, so visitLeaf(this) kept the old behaviour; the visitor-based clause count was said in LUCENE-8811 (#9855) to be not exact; and LUCENE-7293 (#8348) made the classic Highlighter skip spatial queries.
Why it fails: the index query is a conjunct every hit must match, not an alternative implementation; the old behaviour came from two mechanical sweeps, while the class was written to delegate and its author later filed the same omission elsewhere as a bug; block-join children match other documents, while this index query matches the same ones, and this class is public, takes any Query and stays itself after rewrite; LUCENE-8811's stated goal covers "booleans wrapped in other queries"; LUCENE-7293 is about one highlighter, predates QueryVisitor and says nothing about visit or the clause limit; @lucene.experimental marks API stability, not an exemption. Verdict: a defect, on a case that is persuasive rather than conclusive: no javadoc, test or upstream discussion says what this class must report to visitors. The fact-check reviewer from another model family (OpenAI GPT-5.6, reading only) rated the intended case "moderate" and the bug as clearing the no-noise bar narrowly; a maintainer could call it low-priority housekeeping of an experimental API.
Recommended Visit the index query with a FILTER sub-visitor
Visit the index query instead of reporting a leaf: indexQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));, as ConstantScoreQuery does. The repro and the two-symptom test pass, and so do the closest suites, the spatial-extras module and lint. It changes admission: wrapped index queries over the limit, which run today, then throw TooManyNestedClauses.
@Override
public void visit(QueryVisitor visitor) {
indexQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
}All options and trade-offs4 options · 630 words
Option A was run through gradle on 645d4fe14a20; B and C were run with the standalone runner (the patched class compiled in front of the 645d4fe14a20 jars). A is preferred; D only documents the gap.
One line plus an import (+2/-1). FILTER matches how the index query runs: required but not scored (COMPLETE_NO_SCORES), as in ConstantScoreQuery.visit.
@Override
public void visit(QueryVisitor visitor) {
indexQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
}Trade-offs
- Admission changes (worth the CHANGES line). Some queries that run today start throwing
TooManyNestedClauses: with the fix at a limit of 8, the wrapped 20-clauseDisjunctionMaxQueryand the 4 MUST x 5 SHOULD shape throw onrewriteandcount(executed in the fact check). That is the point of the fix, but applications see it. - Visitors see the child. The visitor receives the index query's leaves (
consumeTermsorvisitLeaf) instead of onevisitLeaffor the wrapper, so a visitor that recognisedCompositeVerifyQuerythroughvisitLeafnow sees it only as thegetSubVisitorparent, and the index query's ownacceptFieldgating starts to apply. - From reading only, not run:
UnifiedHighlighter.hasUnrecognizedQuery(UnifiedHighlighter.java:1185-1205) sets its unknown-leaf flag only fromvisitLeaf, and today the wrapper's ungatedvisitLeaf(this)sets it whatever the field. With the fix and aTermQueryindex query (the repro, the in-tree points-only path) the child reportsconsumeTerms, so the flag is not set; with a prefix-tree index query (a field-gatedvisitLeaf) it is set only for the spatial field. - The shape predicate is no longer reported at all; it reads doc values, not terms. Option C keeps that signal.
What the checks validated
Scratch worktree of 645d4fe14a20, gradle, 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;
TestCompositeStrategy,TestQueryEqualsHashCodeandTestGeo3dRpt, the only tests that use the class orCompositeSpatialStrategy: 3 classes, 6 tests, 0 failures, 1 skipped (nightly);- the whole
:lucene:spatial-extras:test: 26 classes, 117 tests, 0 failures, 5 skipped; checkGoogleJavaFormat,ecjLintMain,forbiddenApisMainandrenderJavadocfor spatial-extras: pass;- the finder's two-symptom test passes 2 of 2 with the fix (standalone runner);
- earlier, in the hunt: the model-based harness has 0 of 18 visit mismatches with it (3 without), and
TestCompositeStrategyandTestQueryEqualsHashCodepass with both fixes for this class (this one and LFV-947's), 5 iterations.
indexQuery.visit(visitor.getSubVisitor(BooleanClause.Occur.MUST, this));, as IndexOrDocValuesQuery and BoostQuery do.
Trade-offs
Same effect on term extraction and the clause count. MUST tells occur-aware visitors that the child scores, which it does not here.
What the checks validated
The minimal test and the finder's two-symptom test pass (standalone runner, 645d4fe14a20 jars, assertions on); not run through gradle.
Add visitor.visitLeaf(this); after the line of option A, to report the shape predicate, which does not match on terms.
Trade-offs
Keeps today's signal for visitors that detect the class, or any non-term condition, through visitLeaf; costs one extra counted clause per wrapper, and visitLeaf(this) is not gated by acceptField, so field-filtering visitors still see an unknown leaf on every field.
What the checks validated
The minimal test and the finder's two-symptom test pass (standalone runner, 645d4fe14a20 jars, assertions on); not run through gradle.
Say in the javadoc that visit does not descend into the index query.
Trade-offs
At odds with the general Query#visit guidance for a child every hit must match, and leaves the clause guard bypassable through a public constructor. Workaround meanwhile: visit, or IndexSearcher#rewrite, your own index query before wrapping it.
Solr's RptWithGeometrySpatialField builds its queries with CompositeSpatialStrategy (read from its source, not run), so only the narrow in-tree term case can reach it; an application that relies on maxClauseCount alone to bound an index query it wraps should bound that query itself.
A planning probe, from an ideas list on contracts that committers state, showed this class hiding its index query from term collection. The finder re-ran it, then compared what visitors see for CompositeVerifyQuery(I, P) and for I alone over 9 index-query kinds (a model-based differential harness): 3 of 18 checks mismatched. A public-API JUnit confirmed it.
The method in full181 words
Method: semi-formal (the hunt's tier for model-based testing against an explicit model of the contract).
- Lead. The hunt's planner had an executed lead from an ideas list on committer-stated contracts, written with an independent reviewer from another model family (OpenAI GPT-5.6), and a probe of it, which showed
termCollectorfinding 20 terms for a bare index query and 0 for the wrapped one, and the bare query rejected by the clause limit while the wrapped one ran. The hunt unit re-ran the probe first. - Model-based differential harness. For 9 kinds of index query
I, it comparedQueryVisitor.termCollectorand the clause counter's leaf count ofCompositeVerifyQuery(I, P)with those ofI: 18 checks, 3 mismatches (theTermQueryand the 2-clauseBooleanQueryindex queries lose their terms, and theBooleanQuerycounts 1 leaf instead of 2), 0 with the fix; the same on 10.5.2. - JUnit. Reduced to a public-API test with two methods (terms; clause limit), which fails 2 of 2 with assertions on and off and 20 of 20 over 10 random seeds; later minimized to the one-method test above.
No prior report found: 194 logged tool queries plus manual GitHub, JIRA, mailing-list, downstream and web searches on 2026-10-28, and a GitHub re-check on 2026-10-31. Closest: LUCENE-8849 and LUCENE-9050, the same omission fixed in other classes.
Searches4 searches
- Related, not reports: LUCENE-3041 (#4114, lucene-solr#581: the origin and the design rule); LUCENE-8811 (#9855, the cumulative check); LUCENE-8849 (#9892) and LUCENE-9050 (#10092), the same omission fixed as bugs; open PR #13140 (
DocValuesRewriteMethod, the same kind); #14756, #14759 and open PR #16175 (IndexOrDocValuesQuerycounted twice, the opposite direction; a maintainer on #14756: "for the purpose of visiting, we should visit all variants"); LUCENE-9299 (#10339, an off-by-one in the counter itself); #12538 / #12540 (visitLeafversusacceptField); LUCENE-7293 (#8348, the classic Highlighter skips spatial queries); LUCENE-5579 (#6641, the class's origin). - The only apache/lucene item naming the class is #7338 (LUCENE-6276, matchCost), unrelated.
- Same family, the hunt's own findings (different fix sites): LFV-916, LFV-917, LFV-923, LFV-872; LFV-947 is the same class, another method (
isCacheable). LFV-038 (DocValuesRewriteMethod) is known through open PR #13140. - Limits: keyword search only; GitHub search does not index inline review comments (the relevant threads were read directly); Discussions, chat and private channels were not searched.
| Where | When | How | Found |
|---|---|---|---|
| Tool runs over GitHub issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web | 2026-10-28 | three scripted prior-report searches (identifiers; symptoms; the spatial-extras neighbourhood and highlighting), 194 queries | 124 distinct high or medium hits, none about this bug |
| GitHub (apache/lucene; Solr, lucene-solr, Elasticsearch, OpenSearch, Lucene.NET) | 2026-10-28 | 45 manual searches (class names, visit, visitLeaf, getSubVisitor, termCollector, TooManyNestedClauses, maxClauseCount bypass) and 15 downstream searches; the changed-file lists of all 414 open PRs; full reads of #9855, #4114, lucene-solr#581 (123 comments), #14756, #16175, #13140, #6641, #8348 | only #7338 (unrelated) names the class; no open PR changes it |
| JIRA (LUCENE, SOLR), mailing lists, web | 2026-10-28 | 12 JQL queries, 30 list queries, 21 web queries | spatial design threads and clause-limit threads only |
| GitHub re-check (read-only) | 2026-10-31 | CompositeVerifyQuery in issues and PRs; visitLeaf, QueryVisitor, maxClauseCount, TooManyNestedClauses and spatial-extras on items updated since 2026-10-28; the 56 PRs updated since then; new issues; tracking issue #16937 | nothing new; last commit on the file d0d2aa274fb (2024-06-06) |
Introduced in 8.1.0 by LUCENE-3041 (fbd05167f455, 2019), which added Query#visit and wrote this one as a leaf; the clause-limit half since 9.0.0 (LUCENE-8811). The older extractTerms API had the same gap since 5.2.0. Unchanged on main, branch_11x and 10.6.0.
Versions tested7 rows
Introduced by fbd05167f455e3ce2b2ead50336e2b9c2521cd6c, LUCENE-3041 "QueryVisitor" (lucene-solr#581, issue #4114, author date 2019-03-14), with its branch_8x backport ecf86ecab7a, first released in 8.1.0. git blame at 645d4fe14a20 still attributes CompositeVerifyQuery.java:87-90 to it. Clause-limit half from 9.0.0: 53f56fb7ad44 (LUCENE-8811, #9855, 2019-06-26) built the cumulative check on Query#visit; its 8.x commit was reverted before 8.2.0. Precursor: LUCENE-6425 (f4c05465673, 2015-04-16) removed the class's term delegation before its first release, 5.2.0. Isolation: compiling only a recursive visit into the 8.1.0 and 9.0.0 jars makes the probes pass.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails (gradle and standalone, assertions on and off); the two-symptom test fails 2 of 2; passes with fix A |
| 10.6.0 (Maven Central jars) | affected | minimal test fails, assertions on and off; two-symptom test 2 of 2 |
main a9779ef3c193, branch_10_6 4ae946e5a9b | affected | both symptoms (hunt runs) |
| 10.5.2, 10.0.0, 9.12.3, 9.0.0 | affected | both symptoms |
| 8.11.4, 8.1.0 | term half only | terms missing; no cumulative clause check exists in 8.x |
| 8.0.0, 5.2.0 | older API | no QueryVisitor (compile error); Weight#extractTerms has the same gap |
main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298 (GitHub, 2026-10-31) | same code | the last commit to the file on each is d0d2aa274fb (read, not run) |
Reproduced by an independent reproducer with its own clause shape, a devil's-advocate verifier that also found the in-tree case, an adjudication, a credibility audit, the minimization and this doc's gradle run. Reviews by OpenAI GPT-5.6 could only read code; one rated it real, the other agreed on the root cause.
Checks and verdicts296 words
Every executed check ran on stock jars; the cross-model reviews ran with reading-only tools, so their verdicts rest on reading.
| Check | Verdict | What it found |
|---|---|---|
| Finder (planner lead, model-based differential harness, JUnit) | candidate | 3 of 18 harness checks mismatch, 0 with the fix; the two-symptom JUnit fails 2 of 2 on main (on and off; 20 of 20 over 10 seeds), branch_10_6, 10.5.2 and 9.12.3. |
| Works-as-designed review (OpenAI GPT-5.6, reading only) | real (high) | Intended argument moderate and rejected; preferred a FILTER sub-visitor. A separate re-run fails 2 of 2. |
| Independent reproducer | real | Own JUnit with 4 MUST x 5 SHOULD clauses fails 2 of 2 on main, 10.5.2 and 9.12.3; controls pass; term probe fails on 8.1.0, 8.11.4 and 9.0.0; its fix passes. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (medium), procedural | Could not run Java; agreed on the root cause and that the inputs are legal. A separate control test, asserting the stock behaviour, passes on main and 10.5.2. |
| Intended-behaviour devil's advocate | real (Low) | Built the strongest intended case and rejected it; found the 2015 delegation and the in-tree points-only term loss. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test (seed 7A11BEEF, on and off) and the finder's: 2 of 2 fail each; corrected the in-tree reach. |
| Credibility audit | keep | Finder's test with seed A0D17004: 2 of 2 fail; with the fix shadowed: pass. |
| Minimization | verified | The one-method test fails on 645d4fe14a20, on and off; the fix passes. |
| This doc's fix validation | real; fix validated | Gradle on 645d4fe14a20: stock fails on and off; fix A passes the test, the closest suites, the spatial-extras module and lint. 10.6.0 jars fail; options B and C pass both tests. |
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 385 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,658 words
- Registry title
- CompositeVerifyQuery.visit reports the whole query as a leaf and never visits the index query it rewrites and executes, so QueryVisitor term collection misses the index query's terms and IndexSearcher's cumulative maxClauseCount check does not count its clauses
- Area
- spatial-extras / composite: CompositeVerifyQuery (Query#visit; consumers: QueryVisitor.termCollector and other visit-based term extraction, IndexSearcher's cumulative clause limit)
- Priority
- Low, rank 738: CompositeVerifyQuery hiding its index query from QueryVisitor is the same visitor-hiding class as LFV-917 and LFV-916, with hits still correct and impact mostly limited to application-built wrappers, so it closes those rows after LFV-916.
- Impact class
- minor
- Impact note
- Applications that build a CompositeVerifyQuery with its public, @lucene.experimental constructor around their own index query get no TooManyNestedClauses for the index query's clauses, because the cumulative check counts the whole wrapper as one clause (from 9.0.0 on; executed with a 20-clause index query under maxClauseCount=8, while with the default limit of 1024 the index query needs more than 1024 clauses, spread over nested queries that each stay under the limit [read]), and QueryVisitor.termCollector and other visit-based term extraction see none of the index query's terms (every release since 8.1.0); CompositeSpatialStrategy's own queries are hit only by the term half and only with a points-only prefix tree and IsEqualTo or IsWithin on a point, where the one lost term is a binary spatial cell token; search hits are always correct; default searcher and codec, ordinary scale.
- Summary
- You wrap your own index query, for example TermQuery(body:a) or a 20-clause DisjunctionMaxQuery, in new CompositeVerifyQuery(indexQuery, shapePredicate), then collect its terms with QueryVisitor.termCollector or search it after IndexSearcher.setMaxClauseCount(8). You expect [body:a] and an IndexSearcher.TooManyNestedClauses error, because Query#visit is documented to 'recurse through the query tree, visiting any child queries', the wrapper rewrites its index query and runs it as the approximation that every hit must match, and the same 20 clauses as a bare query or as a sibling FILTER clause are rejected. Instead CompositeVerifyQuery.visit only calls visitor.visitLeaf(this): termCollector returns [] and the clause check counts the whole query as one clause, so the over-limit query runs (its hits are correct).
- Introduced
- fbd05167f455e3ce2b2ead50336e2b9c2521cd6c (LUCENE-3041 'QueryVisitor', apache/lucene-solr#581, issue apache/lucene#4114, by GitHub romseygeek; branch_8x backport ecf86ecab7a34dc5fd77429b92a8a8b5d78f4844, released in 8.1.0) added Query#visit and wrote CompositeVerifyQuery.visit as visitor.visitLeaf(this), unchanged since (git blame on main a9779ef3c193 attributes CompositeVerifyQuery.java:87-90 to fbd05167f45) [read]; the clause-limit consequence appeared with 53f56fb7ad44afab9c78fab2e89737438093247f (LUCENE-8811, #9855: the cumulative clause check moved to IndexSearcher and was built on QueryVisitor), first released in 9.0.0 (its 8.x commit fa3bf88783a was reverted by 193c77fe1a0 before 8.2.0). Precursor: the class was written with term extraction delegated to its index query (489f56def84, LUCENE-5579, #6641, 2015-04-10, by GitHub dsmiley), and the LUCENE-6425 sweep (f4c05465673, #7484, 2015-04-16, by GitHub jpountz) deleted that delegation before any release, so the predecessor Weight#extractTerms already had the gap from 5.2.0 [exec on 5.2.0 and 8.0.0]. Commit-level isolation [exec]: compiling only a recursive visit into the 8.1.0 and 9.0.0 jars makes the visitor probe pass on both and the clause probe pass on 9.0.0. The defect arrived with the API, so there is no earlier working state to bisect. (2019-03-14 (author date of fbd05167f455 and of its branch_8x backport ecf86ecab7a, committed 2019-03-15); 2019-06-26 (author date of 53f56fb7ad44, LUCENE-8811, which made visit() the basis of the cumulative clause limit); precursor 2015-04-16 (author date of f4c05465673, LUCENE-6425))
- First affected version
- 8.1.0 [exec] for the term-collection half (the first release with Query#visit; 8.0.0 has no QueryVisitor: compile error [exec]); 9.0.0 [exec] for the clause-limit half (the first release with the visitor-based cumulative check; 8.1.0 and 8.11.4 have no cumulative limit, so the clause probe does not apply there [exec]); the predecessor Weight#extractTerms API had the same gap from the class's first release, 5.2.0 [exec on 5.2.0 and 8.0.0]
- Affected versions
- Term-collection half: every release from 8.1.0 to 10.6.0 (10.6.0 run as its release revision, the branch_10_6 head 4ae946e5a9b) and main a9779ef3c193; clause-limit half: every release from 9.0.0 on, branch_10_6 and main [exec: the follow-up's visitor probe fails on 8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 4ae946e5a9b and main, and its clause probe fails on 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main; the candidate JUnit fails 2 of 2 on main (-ea and -da; 20 of 20 over 10 random seeds), branch_10_6 (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3 (-ea and -da); the independent reproduction's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3 (-da), and its term-only main() fails on 8.1.0, 8.11.4 and 9.0.0; read: the visit line is unchanged since fbd05167f45/ecf86ecab7a, so the releases not executed are affected too]. The branch_10_6 runs combined the branch head's core and test-framework jars with the 10.5.2 spatial-extras jar and the branch's own CompositeVerifyQuery.java compiled in (byte-identical to main; the other spatial-extras differences are in BBoxStrategy and PointVectorStrategy); not a 10.6.0 regression. Not fixed upstream: CompositeVerifyQuery.java was last changed by d0d2aa274fb (#13440, author date 2024-06-06), so upstream main head 645d4fe14a2 (author date 2026-10-28) and branch_10_6 still have the leaf-only visit [read 2026-10-28]. 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 “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
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-28, read-only): three scripted prior-report searches (identifiers, symptoms and the spatial-extras neighbourhood: 66, 64 and 64 queries over GitHub issues/PRs, commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 124 distinct high or medium hits, the only high one LUCENE-8811 #9855, none about this class), 45 manual GitHub issue/PR searches, 15 downstream searches (Solr, lucene-solr, Elasticsearch, OpenSearch, Lucene.NET), 12 JQL queries on LUCENE and SOLR, 30 mailing-list queries, 21 web queries, the changed-file lists of all 414 open apache/lucene PRs (11 touch spatial-extras; none of them changes CompositeVerifyQuery or a visit method), all issues and PRs created or closed since 2026-10-26, and full reads of #9855, #4114, lucene-solr#581 (107 review and 16 issue comments: none mentions the composite package), #14756, #16175, #13140, #6641, #8788, #8348 and others. The only apache/lucene item that names CompositeVerifyQuery is #7338 (LUCENE-6276, matchCost; unrelated). Refresh 2026-10-28 (GitHub search and REST API, read-only): 'repo:apache/lucene CompositeVerifyQuery' still finds only #7338; 'QueryVisitor', 'maxClauseCount' and 'spatial' updated since 2026-10-27: 0, and 'visit' only two HNSW PRs (#16806, #16731); no PR updated since a recent cutoff touches spatial-extras, QueryVisitor.java or IndexSearcher.java (only #16931, MMapDirectory); the newest issue, #16937, is the tracking issue and does not mention the class; no new commit on the file (last d0d2aa274fb, 2024-06-06). Related, not reports: LUCENE-3041 (#4114, lucene-solr#581: introduced the leaf-only visit, with the review rule 'all compound queries should pull a sub visitor and call visit on each of their sub queries'); LUCENE-8811 (#9855, the visitor-based cumulative check); LUCENE-8849 (#9892) and LUCENE-9050 (#10092), the same kind of omission fixed as bugs in other classes; open PR #13140 (same kind, DocValuesRewriteMethod, registry LFV-038); #14756, #14759 and open PR #16175 (IndexOrDocValuesQuery over-counting, the opposite direction in another class); LUCENE-9299 (#10339, an off-by-one in the counter itself); LUCENE-7293 (#8348: the classic Highlighter skips spatial queries by package, which predates QueryVisitor and does not cover the clause limit); LUCENE-5579 (#6641, introduced the class). Gaps: keyword search only; GitHub Discussions and chat channels were not searched.
- 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; with fix A it passes, and so do the closest suites and the whole spatial-extras module (26 classes, 117 tests, 0 failures, 5 skipped). The 10.6.0 Maven Central jars fail the same way. The hunt ran on maina9779ef3c193, where the class is identical, and on releases back to 5.2.0. On GitHub (2026-10-31) main2f424fd428da,branch_11xd0c0decc6606andbranch_10_691eaab4fb298carry the same file: its last commit on each isd0d2aa274fb(read, not run).
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:33-41 (class javadoc, @lucene.experimental, indexQuery field comment 'approximation (matches more than needed)'), :44-47 (public constructor), :49-56 (rewrite), :87-90 (visit), :93-110 (createWeight runs indexQuery.createWeight directly as the approximation) @ a9779ef3c193lucene/core/src/java/org/apache/lucene/search/Query.java:88-93 (visit javadoc), QueryVisitor.java:54 (visitLeaf 'Called by leaf queries that do not match on terms'), :87-91 (termCollector), IndexSearcher.java:878 and :896 (rewrite runs getNumClausesCheckVisitor), :1194-1199 (TooManyNestedClauses javadoc), ConstantScoreQuery.java:77-78 and IndexOrDocValuesQuery.java:141-144 (peer wrappers recurse) @ a9779ef3c193lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:155 (the only in-tree constructor call) @ a9779ef3c193GitHub REST/search (read-only), 2026-10-28: search/issues queries listed in upstream; repos/apache/lucene/pulls sorted by update and pulls/16931/files; repos/apache/lucene/issues sorted by creation; issues/16937 (no mention); repos/apache/lucene/commits?path=CompositeVerifyQuery.java (d0d2aa274fb) and commits/main (645d4fe14a2)
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:41, :44-47, :49-56, :87-90, :92-118 @ 645d4fe14a20lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:54, :58, :87-98; IndexSearcher.java:871-880, :896-931; BooleanQuery.java:74-86; AbstractKnnVectorQuery.java:93, :479-483 @ 645d4fe14a20lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:481-485 @ 645d4fe14a20lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:105-117, :122-134, :135-156 (constructor call :155; the only other one is CompositeVerifyQuery.java:53); lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:216-229 @ 645d4fe14a20
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Query.java:89; QueryVisitor.java:54, :87; IndexSearcher.java:1194-1195; ConstantScoreQuery.java:77-79; BoostQuery.java:105-107; IndexOrDocValuesQuery.java:141-145 @ 645d4fe14a20lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:134-136 @ 645d4fe14a20lucene/CHANGES.txt:4681-4684, :5800, :6163, :6469-6475; lucene/MIGRATE.md:1533 @ 645d4fe14a20
- Prior reports
GitHub read-only, 2026-10-31 (gh search issues/prs, gh pr list --search updated:>=2026-10-28, gh issue view 16937, repos/apache/lucene/commits?path=CompositeVerifyQuery.java)
- History
GitHub read-only, 2026-10-31 (repos/apache/lucene/commits/<branch> and commits?sha=<branch>&path=lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java): main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298, last commit to the file d0d2aa274fb on each; a Lucene clone: git diff 645d4fe14a20 against origin/main 2f424fd428da, tag releases/lucene/10.6.0 (commit 4ae946e5a9b) and the older local refs origin/branch_11x 603af8703753 and origin/branch_10_6 7e5aec4370e0 (fetched 2026-10-29) for the file: empty
- TestCompositeVerifyQueryVisit.java (one test, public API):
repro/LFV-951/src/org/apache/lucene/spatial/composite/TestCompositeVerifyQueryVisit.java(45 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and the finder's two-symptom test.
assets/LFV-951/bug.png(385 KB)
![Diagram of the bug: CompositeVerifyQuery wraps the index query TermQuery(body:a) and a shape predicate. Search runs the index query as the approximation and verifies each candidate against the shape, so every hit contains body:a. Query#visit stops at the wrapper, which reports itself with visitLeaf, so QueryVisitor.termCollector collects [] instead of [body:a], and IndexSearcher's clause counter counts 1 clause for a wrapped 20-clause index query that, unwrapped, exceeds maxClauseCount = 8. With the fix, visit descends into the index query through a FILTER sub-visitor.](../assets/LFV-951/bug.png)