All findings · Lucene finding · LFV-951 · composite-verify-query-visit-hides-index-query

CompositeVerifyQuery.visit hides the index query it runs, so term extraction and the nested clause limit miss it

CompositeVerifyQuery runs an index query that every hit must match, but its visit() reports only itself, as a leaf. So QueryVisitor.termCollector finds [] instead of [body:a], and the cumulative clause limit counts a 20-clause index query as 1 clause, while hits stay correct.

Summary

Priority
Low, rank 738CompositeVerifyQuery 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
minor
Since
LUCENE-3041 (8.1.0, 2019); clause-limit half since 9.0.0introduced 2019-03-14 · about 7 years
Affected
8.1.0 to 10.6.0 and main; clause-limit half 9.0.0 on
Status
documented
Found by
semi-formal: a model-based differential harness, then a public-API JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background

  1. 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.
  2. 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, calls visitLeaf.
  3. Two users of visit: QueryVisitor.termCollector (term extraction, for example for highlighting) and IndexSearcher's clause limit, which since 9.0 counts every leaf of the whole tree against maxClauseCount (1024 by default) and throws TooManyNestedClauses past it.
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.
The minimal repro: the search runs the index query 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.

The finding, step by step

  1. Wrap TermQuery(body:a) in new CompositeVerifyQuery(indexQuery, shapePredicate) and collect its terms with QueryVisitor.termCollector(terms).
  2. You expect [body:a]: every hit must match the index query, and Query#visit is documented to "visit any child queries". ConstantScoreQuery(TermQuery(body:a)) gives [body:a].
  3. You get []: visit() only calls visitor.visitLeaf(this), and the term collector ignores leaves.
  4. Same line, second effect (9.0 on): with maxClauseCount = 8, a 20-clause DisjunctionMaxQuery alone is rejected with TooManyNestedClauses, but wrapped as the index query it passes IndexSearcher#rewrite and runs, counted as 1 clause.
  5. Hits are correct in every run. One line fixes both effects: visit the index query through a FILTER sub-visitor, as ConstantScoreQuery does. Then a wrapped index query over the limit, which runs today, throws TooManyNestedClauses.

Who is affected

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 code (main 645d4fe14a20)

  1. The index query is a real child. CompositeVerifyQuery.java:41 private final Query indexQuery; // approximation (matches more than needed); :44-47 the public constructor accepts any Query.
  2. It is rewritten and kept. :49-56: rewrite calls indexQuery.rewrite(indexSearcher); if that returns a different query it returns a new CompositeVerifyQuery around it, otherwise super.rewrite(indexSearcher), the query itself. Either way the index query stays in the tree.
  3. It is executed as the approximation. :92-118: createWeight calls indexQuery.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's TwoPhaseIterator (: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).
  4. visit hides it. :87-90: visitor.visitLeaf(this);.
  5. Term extraction sees nothing. QueryVisitor.java:91-98: termCollector overrides only consumeTerms; visitLeaf is a no-op by default (:58). UnifiedHighlighter.extractTerms is query.visit(QueryVisitor.termCollector(queryTerms)) (UnifiedHighlighter.java:481-485; read, not run with this class).
  6. The clause guard counts one. IndexSearcher.java:871-880: rewrite rewrites to a fixed point and then runs query.visit(getNumClausesCheckVisitor()) (:878); the counter (:896-931) adds one per visitLeaf, consumeTerms or consumeTermsMatching call (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 one visitLeaf call whatever it holds, and the class never sends its index query through IndexSearcher#rewrite, so the searcher's clause check never sees it. (AbstractKnnVectorQuery also does not visit its filter, but rewrites it through indexSearcher.rewrite(...), AbstractKnnVectorQuery.java:93, so its filter is still checked.)
  7. What still limits a query. Each BooleanQuery.Builder#add rejects more than maxClauseCount clauses in that one builder (BooleanQuery.java:74-86, "We do the final deep check ... during IndexSearcher.rewrite"). DisjunctionMaxQuery has no build-time check, and nested builders each stay under the limit. A query timeout still applies.

The numbers

  • termCollector over CompositeVerifyQuery(TermQuery(body:a), predicate): []; over the bare TermQuery and over ConstantScoreQuery(TermQuery): [body:a].
  • maxClauseCount = 8, index query = DisjunctionMaxQuery of 20 TermQuery clauses: searcher.rewrite(dmq) throws; BooleanQuery(MUST CompositeVerifyQuery(TermQuery(body:t0), predicate), FILTER dmq) throws; searcher.rewrite(new CompositeVerifyQuery(dmq, predicate)) does not. With a BooleanQuery of 4 MUST sub-queries of 5 SHOULD terms each, the wrapped query passes and count() returns the correct 4.
  • In-tree: CompositeSpatialStrategy over a points-only prefix tree (6 levels) and a serialized shape field, IsEqualTo or IsWithin on Pt(10, 20), builds CompositeVerifyQuery(rpt:s5x1g8, shapeDocVal(sdv) Equals Pt(x=10.0,y=20.0)) around a TermQuery on 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 (IntersectsPrefixTreeQuery and 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 classic Highlighter skips every org.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 a DisjunctionMaxQuery or spread over nested builders that each stay under the limit, runs instead of throwing TooManyNestedClauses. Per-builder checks and query timeouts still apply.
  • In-tree reach is small. CompositeSpatialStrategy reaches only the term half, only with a points-only prefix tree and a non-optimized predicate (IsEqualTo, IsWithin; also Intersects after setOptimizePredicates(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 maxClauseCount suffice; 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).

Minimal repro

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.

TestCompositeVerifyQueryVisit.java (one test, public API)45 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestCompositeVerifyQueryVisit -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
termCollector collects [body:a], the index query's term.
Observed
It collects []: AssertionError: expected:<[body:a]> but was:<[]>, with assertions on and off.

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

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).

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is plain Java)
Command (environment)
# $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

Observed output

# 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=8

Why it looks unintended

  • Query#visit must "Recurse through the query tree, visiting any child queries"; visitLeaf is for queries "that do not match on terms". ConstantScoreQuery and IndexOrDocValuesQuery recurse.
  • 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:54 visitLeaf is "Called by leaf queries that do not match on terms"; :87 termCollector "collects all terms that may match a query". IndexSearcher.java:1194-1195 TooManyNestedClauses is "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 QueryVisitor author, in its review (lucene-solr#581, 2019-02-26): "Yes, all compound queries should pull a sub visitor and call visit on each of their sub queries with it. And visitLeaf is for queries that don't match on terms."
  • 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 with extractTerms(terms) { indexQuery.extractTerms(terms); }. The LUCENE-6425 sweep (#7484, f4c05465673, 2015-04-16) moved extractTerms to Weight and dropped that delegation without a replacement, although its own no-op ConstantScoreWeight.extractTerms says "override if your constant-score query does wrap terms"; both landed before 5.2.0. LUCENE-3041 (fbd05167f45, 2019) then wrote visitLeaf(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/test calls visit or termCollector; 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.

Suggested fix

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.

Visit the index query with a FILTER sub-visitor (validated by the checks) (recommended)

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-clause DisjunctionMaxQuery and the 4 MUST x 5 SHOULD shape throw on rewrite and count (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 (consumeTerms or visitLeaf) instead of one visitLeaf for the wrapper, so a visitor that recognised CompositeVerifyQuery through visitLeaf now sees it only as the getSubVisitor parent, and the index query's own acceptField gating starts to apply.
  • From reading only, not run: UnifiedHighlighter.hasUnrecognizedQuery (UnifiedHighlighter.java:1185-1205) sets its unknown-leaf flag only from visitLeaf, and today the wrapper's ungated visitLeaf(this) sets it whatever the field. With the fix and a TermQuery index query (the repro, the in-tree points-only path) the child reports consumeTerms, so the flag is not set; with a prefix-tree index query (a field-gated visitLeaf) 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, TestQueryEqualsHashCode and TestGeo3dRpt, the only tests that use the class or CompositeSpatialStrategy: 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, forbiddenApisMain and renderJavadoc for 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 TestCompositeStrategy and TestQueryEqualsHashCode pass with both fixes for this class (this one and LFV-947's), 5 iterations.

A MUST sub-visitor (validated by the checks)

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.

Visit the index query and still report the wrapper as a leaf (validated by the checks)

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.

Document the class as opaque (not a fix)

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.

Downstream note

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.

How it was found

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 termCollector finding 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 compared QueryVisitor.termCollector and the clause counter's leaf count of CompositeVerifyQuery(I, P) with those of I: 18 checks, 3 mismatches (the TermQuery and the 2-clause BooleanQuery index queries lose their terms, and the BooleanQuery counts 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.

Prior reports, history and checks

Prior reports

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 (IndexOrDocValuesQuery counted 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 (visitLeaf versus acceptField); 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.
What was searched, when, and what turned up
WhereWhenHowFound
Tool runs over GitHub issues, PRs and commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web2026-10-28three scripted prior-report searches (identifiers; symptoms; the spatial-extras neighbourhood and highlighting), 194 queries124 distinct high or medium hits, none about this bug
GitHub (apache/lucene; Solr, lucene-solr, Elasticsearch, OpenSearch, Lucene.NET)2026-10-2845 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, #8348only #7338 (unrelated) names the class; no open PR changes it
JIRA (LUCENE, SOLR), mailing lists, web2026-10-2812 JQL queries, 30 list queries, 21 web queriesspatial design threads and clause-limit threads only
GitHub re-check (read-only)2026-10-31CompositeVerifyQuery 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 #16937nothing new; last commit on the file d0d2aa274fb (2024-06-06)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal 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) affectedminimal test fails, assertions on and off; two-symptom test 2 of 2
main a9779ef3c193, branch_10_6 4ae946e5a9b affectedboth symptoms (hunt runs)
10.5.2, 10.0.0, 9.12.3, 9.0.0 affectedboth symptoms
8.11.4, 8.1.0 term half onlyterms missing; no cumulative clause check exists in 8.x
8.0.0, 5.2.0 older APIno QueryVisitor (compile error); Weight#extractTerms has the same gap
main 2f424fd428da, branch_11x d0c0decc6606, branch_10_6 91eaab4fb298 (GitHub, 2026-10-31) same codethe last commit to the file on each is d0d2aa274fb (read, not run)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (planner lead, model-based differential harness, JUnit) candidate3 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 realOwn 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), proceduralCould 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 keepFinder's test with seed A0D17004: 2 of 2 fail; with the fix shadowed: pass.
Minimization verifiedThe one-method test fails on 645d4fe14a20, on and off; the fix passes.
This doc's fix validation real; fix validatedGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,658 words

Finding record

LFV-951, snapshot of 2026-11-06

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 main a9779ef3c193, where the class is identical, and on releases back to 5.2.0. On GitHub (2026-10-31) main 2f424fd428da, branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 carry the same file: its last commit on each is d0d2aa274fb (read, not run).

Lucene and upstream sources

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) @ a9779ef3c193
  • lucene/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) @ a9779ef3c193
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:155 (the only in-tree constructor call) @ a9779ef3c193
  • GitHub 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 @ 645d4fe14a20
  • lucene/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 @ 645d4fe14a20
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:481-485 @ 645d4fe14a20
  • lucene/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 @ 645d4fe14a20
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:134-136 @ 645d4fe14a20
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and the finder's two-symptom test.

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