All findings · Lucene finding · LFV-872 · indri-query-visit-leaf-hides-clauses

IndriAndQuery hides its clauses from every QueryVisitor

IndriQuery.visit reports the whole query as a leaf, so tools that walk an IndriAndQuery with a QueryVisitor never see its clauses: term extraction returns nothing, term-extraction highlighting marks nothing, and the cumulative clause limit counts it as one clause. Search hits are right; since 8.9.0.

Summary

Priority
Low, rank 359In the rarely used IndriAndQuery, and search hits and scores are unaffected.
Impact
silent wrong results
Since
8.9.0 (2021), when the Indri classes were addedintroduced 2021-01-29 · about 5 years
Affected
8.9.0 to 10.6.0 and main (8 releases and 10.6.0 run)
Status
documented
Found by
bytecode scan, then bounded-exhaustive comparison with BooleanQuery

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

Tools that need to know what a query contains walk it with a QueryVisitor. Every query that holds other queries must pass the visitor on to them; IndriAndQuery does not.

Summary card: IndriAndQuery hides its clauses from every QueryVisitor; IndriQuery.visit is visitor.visitLeaf(this). Panel 1, where the visitor goes for the same two clauses body:president and body:washington: the BooleanQuery's visitor reaches both term clauses and termCollector gets both terms; the IndriAndQuery's only call is visitLeaf(this), the clauses are never visited and termCollector gets none. Panel 2, expected against actual for each tool: termCollector; UnifiedHighlighter term extraction (the BooleanQuery's passage has both words marked, the IndriAndQuery's none); the clause limit at maxClauseCount 4 with 6 clauses (the DisjunctionMaxQuery throws TooManyNestedClauses, the IndriAndQuery is counted as 1 and runs with 1 hit); RamUsageEstimator.sizeOf over 1,000 clauses. Footer: search hits are right, doc 0 for both queries.
The repro's example: a visitor walks the BooleanQuery into both term clauses but stops at the IndriAndQuery root, so term collection, highlighting, the clause limit and the RAM estimate see no clauses, while search finds the document for both queries. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build an IndriAndQuery of two term clauses, body:president and body:washington, and search a one-document index ("president washington was the first leader"): 1 hit, the same as the BooleanQuery over the same clauses.
  2. Ask which terms it may match with QueryVisitor.termCollector. You expect {body:president, body:washington}, as for the BooleanQuery. You get []. The UnifiedHighlighter in term-extraction mode uses the same collector and returns the passage with no <b> marks.
  3. Set maxClauseCount to 4: a DisjunctionMaxQuery and a nested BooleanQuery over 6 term clauses throw TooManyClauses. An IndriAndQuery of the same 6 clauses runs (1 hit), and so does a BooleanQuery of two 3-clause IndriAndQuerys. With a limit of 16, a 2,000-clause IndriAndQuery runs too.
  4. Why: IndriQuery.visit is visitor.visitLeaf(this), so the visitor is told "leaf" and never sees the clauses, although Query#visit must "recurse through the query tree, visiting any child queries". The same root makes RamUsageEstimator.sizeOf return 1,024 bytes for 1 clause and for 1,000 (BooleanQuery: 1,169,024).

Who is affected

Applications that build an IndriAndQuery (no Lucene parser produces one) and inspect it with a visitor-based tool: their own term extraction, the UnifiedHighlighter outside weight-matches mode, IndexSearcher's cumulative clause limit, or the query RAM estimate. Default codec and settings, any index size. Search hits stay right, so it goes unnoticed.

Mechanism, terms and numbers687 words

Tools that need to know what a query contains (term extraction, highlighting, IndexSearcher's cumulative clause limit, the query RAM estimate) walk the query tree with a QueryVisitor, and every query that holds other queries is supposed to pass the visitor on to them. IndriAndQuery holds a list of clauses, but IndriQuery.visit only calls visitor.visitLeaf(this). So for an IndriAndQuery of body:president and body:washington, QueryVisitor.termCollector returns [] instead of both terms, the UnifiedHighlighter in term-extraction mode highlights nothing, a 6-clause IndriAndQuery runs at maxClauseCount=4 while a DisjunctionMaxQuery over the same clauses is refused, and RamUsageEstimator.sizeOf gives 1,024 bytes for 1 clause and for 1,000 (on main the query cache sizes cached keys this way). Search hits are right (matching and scoring never call visit), and no error is raised. It has been there since the Indri classes were added (LUCENE-9537, first released in 8.9.0). A one-method fix that visits every clause through a SHOULD sub-visitor makes all repros pass, and the search-package and highlighter test suites pass with it.

Terms

  • QueryVisitor: Lucene's query-introspection API. Query#visit is documented to "Recurse through the query tree, visiting any child queries". A query with children pulls a sub-visitor with getSubVisitor(occur, this) and visits each child with it; a query that matches on terms calls consumeTerms; visitLeaf is for "leaf queries that do not match on terms".
  • Who walks queries this way: QueryVisitor.termCollector ("collects all terms that may match a query"), the UnifiedHighlighter's term extraction, IndexSearcher's cumulative clause check (run by IndexSearcher.rewrite), RamUsageEstimator.sizeOf(Query), and on main the LRUQueryCache key accounting that uses it.
  • IndriAndQuery: a core query (since 8.9.0, LUCENE-9537) built from a List<BooleanClause>; its base class IndriQuery keeps the list, and IndriAndWeight builds a Weight for every clause and, per segment, a scorer for each clause that has one.

The bug

IndriQuery.visit (IndriQuery.java:71-74 at a9779ef3c193) is one line, visitor.visitLeaf(this). The visitor is told the whole query is a leaf that does not match on terms, and the clauses are never handed to it. Each consumer then sees an empty box:

  1. termCollector collects only from consumeTerms, and visitLeaf is a no-op: it returns [].
  2. The UnifiedHighlighter's extractTerms is query.visit(QueryVisitor.termCollector(...)); it is used whenever the highlighter is not in weight-matches mode (withWeightMatches(false), or any of multi-term, strict-phrase or passage-relevancy handling turned off). No terms, so no highlights.
  3. IndexSearcher.rewrite runs a clause-counting visitor that adds one per visitLeaf, consumeTerms or consumeTermsMatching call, so a whole IndriAndQuery counts as one clause. Neither Indri class overrides rewrite, and the IndriAndQuery constructor has no size check (BooleanQuery.Builder.add refuses more than maxClauseCount clauses at build time).
  4. RamUsageEstimator.sizeOf(Query) starts at the default size for the root and adds nothing for a root that calls visitLeaf, so it is 1,024 bytes whatever the clauses hold.

Matching and scoring do not use visit (only the clause check before a search does): IndriAndWeight iterates the clause list itself, so the hits are right (the security probe found the same hit sets as the SHOULD BooleanQuery for 1, 2, 10 and 50 clauses; it did not compare scores).

The numbers (executed on main)

toolIndriAndQueryBooleanQuery / other composite (expected)
termCollector, clauses body:president, body:washington[]{body:president, body:washington}
recording visitor (independent reproducer)only visitLeaf(IndriAndQuery)a sub-visitor, then each term
UnifiedHighlighter, term extraction"president washington was the first leader""<b>president</b> <b>washington</b> was the first leader"
maxClauseCount=4, 6 term clausesIndriAndQuery(6) and BooleanQuery(IndriAndQuery(3), IndriAndQuery(3)) run, 1 hitDisjunctionMaxQuery(6) and nested BooleanQuery(3+3) throw
maxClauseCount=16, 2,000 clauses (security probe)runs, 60,000+ hitsBooleanQuery.Builder throws while adding
RamUsageEstimator.sizeOf1,024 bytes for 1 clause and for 1,0001,169,024 bytes for the same 1,000
LRUQueryCache key accounting, main only (independent reproducer)192 bytes for 16 clauses and for 5003,136 bytes for the 16-clause BooleanQuery; with the fix, 3,136 and 92,192 for the IndriAndQuery

Not claimed: in the UnifiedHighlighter's default weight-matches mode the IndriAndQuery is not highlighted either, but for another reason (IndriAndWeight does not implement Weight#matches, whose default reports a match with no term positions); the visit fix does not change that.

Exposure and likelihood218 words
  • Who is exposed. Only application code builds an IndriAndQuery: in the whole lucene/ tree no parser or builder produces one (only IndriAndQuery.java itself, IndriAndWeight.java and TestIndriAndQuery.java mention it). Every such query is affected, every time, for every visitor-based consumer.
  • Term extraction and highlighting. Custom code that calls termCollector (for example to log, cache or route on query terms) gets nothing. The UnifiedHighlighter uses it whenever it is not in weight-matches mode. Two clauses and one document are enough.
  • Clause limit. The cumulative check counts the whole IndriAndQuery as one clause, at any nesting depth, so it only matters once counting the clauses would trip the limit: more than maxClauseCount + 1 calls (default 1,025), since the counter checks before it increments. A QueryTimeout still stops a large query.
  • RAM estimate. RamUsageEstimator.sizeOf ignores the clauses in every release since 8.9.0; on main (unreleased, #15124) LRUQueryCache sizes cached query keys with it, which needs an application-installed cache (caching is off by default) and a non-scoring use, and grows with the clauses, about 184 bytes per term clause (a cached 16-clause IndriAndQuery: 192 bytes against 3,136 with the fix; 500 clauses: 192 against 92,192; independent reproducer).
  • How likely. IndriAndQuery is a rarely used API, and the visible symptoms are in tools that inspect the query, not in search results.

Minimal repro

The test shows that IndriQuery.visit calls only visitor.visitLeaf(this), so a QueryVisitor never reaches the clauses of an IndriAndQuery, although Query#visit is documented to visit any child queries.

TestIndriAndQueryVisit.java (JUnit test)41 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.search;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndriAndQueryVisit extends LuceneTestCase {

  public void testTermCollectorSeesClauseTerms() {
    Term a = new Term("f", "a");
    Term b = new Term("f", "b");
    Query query =
        new IndriAndQuery(
            List.of(
                new BooleanClause(new TermQuery(a), Occur.SHOULD),
                new BooleanClause(new TermQuery(b), Occur.SHOULD)));
    Set<Term> terms = new HashSet<>();
    query.visit(QueryVisitor.termCollector(terms));
    // expected: both clause terms [f:a, f:b], as for a BooleanQuery of the same clauses; actual: []
    assertEquals(Set.of(a, b), terms);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-872/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestIndriAndQueryVisit
Expected vs actual
QueryVisitor.termCollector over IndriAndQuery(f:a, f:b) should collect [f:a, f:b], as it does for a BooleanQuery of the same clauses; actual [], with -ea and -da.

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

More files, environment, full output35 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[f:a, f:b]> but was:<[]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.TestIndriAndQueryVisit.testTermCollectorSeesClauseTerms(TestIndriAndQueryVisit.java:39)

Why it looks unintended

  • Query#visit must "recurse through the query tree, visiting any child queries"; IndriAndQuery holds clauses but reports itself as a leaf.
  • The clause limit counts "total clauses cumulatively in all of its children", for "all queries, rather than only booleans" (LUCENE-8811).
  • visitLeaf(this) was never discussed or tested; the same omission in other classes was fixed as a bug (LUCENE-8849, LUCENE-9050).
The full argument247 words
  • Javadoc. Query#visit: "Recurse through the query tree, visiting any child queries." QueryVisitor#visitLeaf: "Called by leaf queries that do not match on terms". termCollector: "collects all terms that may match a query". TooManyNestedClauses: "Thrown when a client attempts to execute a Query that has more than getMaxClauseCount() total clauses cumulatively in all of its children."
  • CHANGES. LUCENE-3041 (the visitor API): queries should "either pass the visitor down to any child queries, or call a visitX() or consumeX() method on it". LUCENE-8811 (9.0.0): the clause checks "are now implemented using a QueryVisitor and apply to all queries, rather than only booleans".
  • The API designer's rule (review of the visitor API, apache/lucene-solr#581, 2019-02-26, @romseygeek): "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."
  • Tests. TestQueryVisitor.testExtractTermsEquivalent asserts that termCollector reaches terms inside nested BooleanQuery and BoostQuery composites; TestMaxClauseLimit.testLargeDisjunctionMaxQuery asserts that a non-Boolean composite with 1,050 children throws TooManyNestedClauses. TestIndriAndQuery checks hit counts only.
  • History. visitLeaf(this) is in the first commit of the introducing PR (apache/lucene-solr#2097, 2020-11-24) and unchanged since on the main line (on branch_8x, which shipped 8.9.0, Query#visit then still defaulted to the same visitLeaf(this)); no review comment, issue comment (#10577, #11194, #463) or commit message mentions it.
  • Precedent. LUCENE-8849 ("DocValuesRewriteMethod.visit wasn't visiting its embedded query", 8.6.0) and LUCENE-9050 ("MultiTermIntervalsSource.visit() was not calling back to its visitor", 8.3.1) were fixed as bugs.
The devil's-advocate check271 words

The strongest case for "intended": the visitor API lets a composite be opaque, and several are on purpose (ToParentBlockJoinQuery, ToChildBlockJoinQuery, DrillDownQuery, MoreLikeThisQuery and CompositeVerifyQuery call visitLeaf(this); block-join highlighting was dropped by design). ToParentBlockJoinQuery gets past the cumulative limit the same way. RamUsageEstimator.sizeOf is documented as an estimate. And maintainers see Indri as marginal (#11194 proposes moving it to the sandbox).

Why it is weaker than the contract: most precedents have a reason that does not apply here. The block-join children match other documents. DrillDownQuery and MoreLikeThisQuery rewrite to a BooleanQuery, which the clause check (run after rewriting) sees; a visitor applied to the query as given, such as the UnifiedHighlighter's term extraction, does not, so for those consumers they are as opaque as IndriAndQuery. The closest case, CompositeVerifyQuery (spatial-extras), holds one same-document child and calls visitLeaf(this), but it is @lucene.experimental, its child is an approximation that "matches more than needed", and a shape predicate decides the final match, so its child's terms only approximate what it matches. IndriAndQuery is in core, not experimental, has no rewrite, and its clauses are the match: a plain disjunction over the same document. The estimate argument only explains why 1,024 follows from the leaf, which is why the RAM effect is a consequence, not the headline. "Marginal" bears on priority, not on whether it is a bug: the classes are public, not experimental, and shipped since 8.9.0. Verdict: most likely not intended (a defect), or at least a contradiction with the documented visit contract; maintainers could still choose to keep Indri opaque and document it.

Suggested fix

Recommended Visit every clause through a SHOULD sub-visitor (validated)

Make IndriQuery.visit pass a SHOULD sub-visitor to every clause (+6/-1 in IndriQuery.java), since the Indri scorers match the union of the clauses. All repro tests pass, and so do 2,353 tests (the search package plus TestRamUsageEstimator) and 723 highlighter tests, with assertions on and off. Oversized Indri queries then hit the clause limit, as other composites do.

@Override
public void visit(QueryVisitor visitor) {
  // The Indri scorers match the union of all clauses, whatever their Occur, so every clause is
  // visited as a SHOULD clause.
  QueryVisitor v = visitor.getSubVisitor(BooleanClause.Occur.SHOULD, this);
  for (BooleanClause c : clauses) {
    c.query().visit(v);
  }
}
All options and trade-offs5 options · 656 words

One fix was validated on the module test suites; an Occur-based variant was validated on a narrower set.

Visit every clause through a SHOULD sub-visitor (validated) (recommended)

IndriQuery.visit pulls visitor.getSubVisitor(Occur.SHOULD, this) and visits each clause's query with it. SHOULD describes what the clauses do today: IndriAndQuery matches the union of its clauses whatever their Occur (a separate finding), so a MUST_NOT clause, which still matches, keeps its terms visible. Fixed in the base class, so a future IndriOrQuery (open PR #463) inherits it.

@Override
public void visit(QueryVisitor visitor) {
  // The Indri scorers match the union of all clauses, whatever their Occur, so every clause is
  // visited as a SHOULD clause.
  QueryVisitor v = visitor.getSubVisitor(BooleanClause.Occur.SHOULD, this);
  for (BooleanClause c : clauses) {
    c.query().visit(v);
  }
}

Trade-offs

  • Behaviour change: an IndriAndQuery whose clauses take the cumulative count over the limit now throws TooManyNestedClauses, like other composites. The counter checks before it increments, so at the default 1,024 an IndriAndQuery of 1,025 term clauses still runs and one of 1,026 throws (read; the executed check used 6 clauses at a limit of 4). Queries under the limit keep their hits; oversized ones that ran before now fail. Mention it in CHANGES.
  • RamUsageEstimator.sizeOf and, on main, the query cache now account for the clauses (a cached 16-clause IndriAndQuery: 3,136 bytes, equal to the BooleanQuery; 500 clauses: 92,192).
  • Visitors now also receive callbacks from clauses that need rewriting (for example a PrefixQuery). Searching such a query already fails at createWeight, because IndriAndQuery does not rewrite its clauses (a related finding), but visitor-only consumers (term extraction, highlighting, sizeOf, custom visitors) never call createWeight, so for them these callbacks are new.
  • Does not fix the blank highlights in the default weight-matches mode, which needs Weight#matches in IndriAndWeight.
  • Not benchmarked (a visit walks the clause list once).

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle, seed DEADBEEF: :lucene:core, every test class of org.apache.lucene.search plus util.TestRamUsageEstimator, with the 3 repro classes: 218 classes, 2,353 tests, 0 failures, with assertions on and off (TestMaxClauseLimit, TestQueryVisitor, TestLRUQueryCache, TestBooleanQuery, TestIndriAndQuery among them).
  • Whole :lucene:highlighter with the highlighter repro: 35 classes, 723 tests, 0 failures, assertions on and off.
  • :lucene:core:check -x test and :lucene:highlighter:check -x test with the fix and both tests: BUILD SUCCESSFUL.
  • The repro runner: the finder's and the reproducer's own fixes (the same code) pass their repros.

Visit each clause with its own Occur (validated on a narrow set)

Mirrors BooleanQuery.visit: c.query().visit(visitor.getSubVisitor(c.occur(), this)) for every clause.

for (BooleanClause c : clauses) {
  c.query().visit(visitor.getSubVisitor(c.occur(), this));
}

Trade-offs

Right once IndriAndQuery honours (or rejects) MUST, FILTER and MUST_NOT. Today it hides a MUST_NOT clause's terms from termCollector (the default sub-visitor for MUST_NOT is empty) although that clause still produces matches. The clause count includes MUST_NOT clauses either way.

What the checks validated

The 3 repro classes, TestIndriAndQuery, TestQueryVisitor and TestMaxClauseLimit (22 tests) and the highlighter repro pass, assertions on. Not run on the whole package or with assertions off.

Document Indri queries as opaque to QueryVisitor

Say in the javadoc that term extraction, highlighting and the clause limit do not see Indri clauses.

Trade-offs

A legitimate maintainer choice, but it contradicts Query#visit's contract and leaves the cumulative clause limit not counting the clauses. Not run.

Override visit in IndriAndQuery only

The same loop in the subclass instead of the base class.

Trade-offs

Works for today's one subclass; a future IndriOrQuery would repeat the bug. Not run.

Workaround without a Lucene change

Count getClauses() before searching or set a QueryTimeout; walk getClauses() yourself for term extraction; or wrap the query in your own Query whose visit descends. Such a wrapper must also delegate createWeight, equals, hashCode and toString, and its rewrite must keep the wrapper rather than return the bare IndriAndQuery. Not run.

Trade-offs

Every application has to know about it.

Downstream note

An application that relies on maxClauseCount alone to bound user-built IndriAndQuery clause lists is not protected: it should count the clauses itself or set a QueryTimeout.

How it was found

An automated bytecode scan of every concrete Query class flagged that IndriAndQuery never hands its clauses to a visitor. A later hunt compared it with the equivalent BooleanQuery on all 1,884 small clause lists against a Z3 model: term collection differed in 1,845, the clause count in 1,860. A public-API JUnit test then reproduced it.

The method in full182 words

Method: semi-formal (an automated scan, then bounded-exhaustive translation validation of the real core jar against a Z3 model).

  • First signal (2026-10-26). An earlier unit scanned the bytecode of every concrete Query class in the stock main jars (JDK ClassFile API) for contract checks; its rows VISIT-MISS and VISIT-NOSUB flagged that IndriAndQuery's clauses are never handed to a visitor. That unit registered the related rewrite bug and recorded this visit root as separate.
  • Hunt (2026-10-28). An outside-reviewer probe pack re-raised it (termCollector returns []). The finder built a bounded-exhaustive harness: every clause list over 3 terms and 4 Occur values (1,884 queries), run through the real jar and compared with the same clauses in a BooleanQuery and with a Z3 model of the expected visitor results. termCollector differed in 1,845 lists and the clause-count visitor in 1,860, with 0 mismatches against the model's prediction for the leaf-only visit.
  • The finder then wrote a public-API JUnit test (term collection, clause limit, RAM estimate) and a highlighter test, and found the RAM-estimate and query-cache consumers by searching for callers of Query.visit.

Prior reports, history and checks

Prior reports

No prior report found: 193 queries over GitHub issues, PRs and commits, the mailing lists, the JIRA archive, downstream projects and the web, plus the full introducing PR, searched 2026-10-28. Nearest: the same omission fixed in other classes (LUCENE-8849, LUCENE-9050).

Searches5 searches

Nobody has reported that IndriQuery.visit treats the query as a leaf, or any of its symptoms (empty termCollector, Indri clauses not counted by maxClauseCount, the RAM under-estimate, missing highlights). None of the hits is about this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr)2026-10-283 scripted prior-report searches (identifiers, symptoms, history keys) and 39 GraphQL searches (38 plus the broad repo:apache/lucene Indri search): Indri with visit, visitor, QueryVisitor, visitLeaf, termCollector, highlight, maxClauseCount, TooManyClausesno report
Threads read in full2026-10-28lucene-solr#2097 (all review comments, all 24 commits), #10577, #11194, #11290, #463, #12295, #9855, #15097, #15124no mention of visit; #2097's one design question (r532221029) is about which clause types are supported
Open and recent PRs2026-10-28changed files of all 410 open PRs; the 76 items updated since 2026-10-26; Indri/visitLeaf items updated since 2026-10-26 (re-read by the fact check)only #463, #469, #669, #11928, #12295 touch Indri files; none touches visit
Mailing lists and JIRA2026-10-28dev@, java-user@, issues@, commits@ (a scripted prior-report search and 9 direct queries); JIRA archive (3 queries)only LUCENE-9537, LUCENE-10157 and smoothing-score threads
Downstream and web2026-10-28Elasticsearch, OpenSearch, Solr trackers; the Lucindri repositories; 7 web searches (several throttled)no report

History

Present since the Indri classes were added (LUCENE-9537, 9cc5c9b7983, merged 2021-01-29; first release 8.9.0); visit has never changed. Affects 8.9.0 to 10.6.0 and main; the clause-limit effect since 9.0.0.

Versions tested7 rows

Introduced by LUCENE-9537 "Add initial Indri search engine functionality to Lucene" (apache/lucene-solr#2097 by @cammiemw, squash-merged 2021-01-29 as 9cc5c9b7983; cherry-picked to branch_8x as a6125fb9385, released in 8.9.0). The visitLeaf(this) body is in the PR's first commit 087d9d2a07c (2020-11-24). The only later main-line commit on IndriQuery.java, 2ee537ce160 (#13261, 2024), changes toString only. Not a regression, so no bisect.

When each effect appeared: term collection and sizeOf with the class in 8.9.0; the cumulative clause limit in 9.0.0 (LUCENE-8811); the query-cache key accounting on main only (#15124, unreleased).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedevery repro fails with assertions on and off; upstream main c5ab3e57b9ba still has visitLeaf(this)
10.6.0-SNAPSHOT (branch_10_6) 4ae946e5a9b9, the revision released as 10.6.0 on 2026-10-29 affectedthe finder's JUnit fails 3/3 with assertions off; this doc's core test fails 3/3 and the highlighter test 1/1 with assertions on and off (fact check)
10.5.2, 10.0.0 affectedthis doc's test fails 3/3; highlighter test fails on 10.5.2
9.12.3, 9.1.0, 9.0.0 affectedterm collection, sizeOf and the clause limit fail (core-only checks on 9.0.0 and 9.12.3); the finder's JUnit fails 3/3 on 9.1.0 and 9.12.3
8.11.4, 8.10.1, 8.9.0 no cumulative limitterm collection and sizeOf fail; the cumulative clause limit does not exist before 9.0.0
8.8.2 not affectedno Indri classes
fix (main + one-method visit) not affectedevery repro passes; core search and highlighter suites pass

Independent checks

An independent reproducer and an intended-behaviour devil's advocate each reproduced it and called it real; the adjudication confirmed it with a new seed. Reviews by another model family could only read code: one called it real, the other defaulted to "not reproduced" but agreed on the root cause.

Checks and verdicts330 words

Every check ran on main a9779ef3c193, except that the cross-model reviews read the sources at ea57360912ed (the cited Indri, Query, QueryVisitor and IndexSearcher files are identical at both). No reviewer from another model family executed code: the reviews could only read code, and the separate re-runs ended in setup_error (wrong package for the highlighter test class). The executed checks are the finder's, the verifiers', the adjudication's, the registry re-runs, a credibility audit, this doc's own runs and the fact check's.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn test written from the claim (recording visitor, termCollector, clause limit at 8 with DisjunctionMaxQuery and nested-BooleanQuery controls, sizeOf over 500 clauses: 585,024 vs 1,024 bytes) fails 4/4 on main (on and off), 10.5.2 and 9.12.3; its fix passes and upstream TestIndriAndQuery stays green; found that the query-cache effect is main only.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, low (procedural)Could not run code, so it voted not real on procedure only; read ea57360912ed; root cause agrees, inputs in contract, intended argument weak.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real, mediumRead ea57360912ed only; rated the "composites may be opaque" case weak. A separate re-run of the finder's repro ended in setup_error; the cross-model review's own re-runs fail on main and pass with the fix.
intended-behaviour devil's advocate realBuilt the opaque-composite case from the block-join, DrillDown and MoreLikeThis precedents and rejected it; re-ran on main and 10.5.2.
adjudication confirmed, highWith a new seed the reproducer's test fails 4/4 with assertions on and off and passes 4/4 with the fix.
Registration check and credibility audit keepRe-ran the finder's test (3/3 fail on and off, 3/3 pass with the fix) and the highlighter test (1/1 fail).
this doc's validation fix validatedGradle on stock and with the fix (search package, highlighter module, static checks), assertions on and off.

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

Finding record

LFV-872, snapshot of 2026-11-06

Registry title
IndriQuery.visit reports the whole query as a leaf and never visits its clauses, so QueryVisitor.termCollector, UnifiedHighlighter term extraction, IndexSearcher's cumulative clause limit and RamUsageEstimator.sizeOf all ignore an IndriAndQuery's clauses
Area
search / IndriQuery (QueryVisitor traversal of Indri composite queries; consumers: QueryVisitor.termCollector, IndexSearcher's cumulative clause limit, RamUsageEstimator.sizeOf(Query), UnifiedHighlighter)
Priority
Low, rank 359: In the rarely used IndriAndQuery, and search hits and scores are unaffected.
Impact class
silent wrong results
Impact note
Applications that build an IndriAndQuery (a rarely used core API that no Lucene query parser produces) get wrong answers, with no error, from every QueryVisitor-based tool on every such query: QueryVisitor.termCollector returns no terms, the UnifiedHighlighter in term-extraction mode (withWeightMatches(false)) highlights nothing, IndexSearcher's cumulative maxClauseCount check (since 9.0.0) counts the whole IndriAndQuery as one clause whatever it holds, and RamUsageEstimator.sizeOf returns the 1024-byte default (on main only, LRUQueryCache's key accounting inherits this, a few KB per cached entry); search hits and scores are unchanged; default codec, IndexWriterConfig and searcher, ordinary scale; every release since 8.9.0.
Summary
You build an IndriAndQuery of ordinary TermQuery clauses, for example f:a and f:b, and inspect it with a QueryVisitor, as term extraction, highlighting, IndexSearcher's clause-count check and the query RAM estimate do. You expect the visitor to reach both clauses, as it does for a BooleanQuery over the same clauses, because Query#visit is documented to 'recurse through the query tree, visiting any child queries'. Instead IndriQuery.visit only calls visitor.visitLeaf(this), so termCollector returns [], with maxClauseCount=4 a 51-clause IndriAndQuery and BooleanQuery(IndriAndQuery(3), IndriAndQuery(3)) both run while the same nesting of BooleanQuery clauses throws TooManyClauses, and RamUsageEstimator.sizeOf gives 1,024 bytes for 1,000 clauses against 1,169,024 for the BooleanQuery.
Introduced
9cc5c9b7983cf181280e18819f8516dc16ccf1ef (LUCENE-9537 'Add initial Indri search engine functionality to Lucene', now issue apache/lucene#10577; PR apache/lucene-solr#2097 by GitHub cammiemw, squash-merged 2021-01-29), released through its branch_8x cherry-pick a6125fb93855 (CHANGES entry moved to 8.9.0 by 4d839225b147); the visitLeaf(this) body is already in the PR's first commit 087d9d2a07c (authored 2020-11-24) and was never discussed in review [read: git log -L on IndriQuery.visit and git log -S visitLeaf in the full-history clone show only 9cc5c9b7983; the one later commit on IndriQuery.java, 2ee537ce160 (#13261, 2024-04-04), changes toString only; exec: fails on 8.9.0, the first release with the class, while 8.8.2 has no Indri classes]; not a regression, so no bisect (2021-01-29 (lucene-solr#2097 squash-merged and cherry-picked to branch_8x the same day; the visitLeaf(this) line was authored 2020-11-24 in the PR's first commit))
First affected version
8.9.0 [exec] (the first release with the Indri classes; 8.8.2 has none and the repro does not compile there [exec]); on the main line 9.0.0 [exec]. The cumulative clause-limit effect starts at 9.0.0, where IndexSearcher's visitor-based limit first shipped (LUCENE-8811) [exec]
Affected versions
8.9.0 to 8.11.4 and every 9.x and 10.x release, 9.0.0 to 10.6.0 [exec: a core-only main() (recording visitor, termCollector, sizeOf) fails on 8.9.0 (-ea and -da), 8.10.1, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; a clause-limit main() fails on 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main and does not compile on 8.11.4 (no cumulative limit before 9.0); the finder's 3-test JUnit fails 3 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 head 4ae946e5a9b9 and main (-ea and -da); the UnifiedHighlighter JUnit fails on 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 4 of 4 on 9.12.3, 10.5.2 and main; read: git tag --contains 9cc5c9b7983, and visit is byte-identical at the 9.0.0, 9.12.3, 10.0.0 and 10.5.2 tags and on main]; the LRUQueryCache key under-count is main only (GITHUB#15124, e706267b893, CHANGES 11.0.0, in no release tag) [exec, the independent reproduction: on main a cached 16-clause IndriAndQuery is accounted at 192 bytes against 3,136 for the BooleanQuery; no Indri-specific difference on 10.5.2]; upstream main c5ab3e57b9ba, branch_10x and branch_10_6 still call visitLeaf(this) [read 2026-10-28; refresh: still only 9cc5c9b7983 and 2ee537ce160 on IndriQuery.java]; 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 Z3, exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only, 193 queries: 3 scripted prior-report searches (135 queries over GitHub issues, PRs and commits, downstream Elasticsearch/OpenSearch/Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive), 39 GitHub GraphQL searches, 9 direct list and 3 JIRA queries, 7 web searches; full reads of #10577, #11194, #11290, #463, #12295, #9855, #15097, #15124 and lucene-solr#2097 (all review comments and all 24 PR commits' patches); a changed-file scan of all 410 open apache/lucene PRs and the 76 items updated since 2026-10-26; the Lucindri repositories), plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's own searches; refresh 2026-10-28: no apache/lucene issue or PR mentioning Indri updated since 2026-10-26, no QueryVisitor or visitLeaf item updated since 2026-10-25, the newest PRs (to #16926) and issues (to #16928) unrelated, and upstream commits on IndriQuery.java still only 9cc5c9b7983 (author date 2021-01-29) and 2ee537ce160 (author date 2024-04-04). Related, not this bug: same-contract precedents in other classes fixed as bugs, LUCENE-8849 (#9892, DocValuesRewriteMethod.visit; registry LFV-038 is the related DocValuesRewriteMethod case), LUCENE-9050 (#10092, MultiTermIntervalsSource.visit empty) and #12686 (IndexOrDocValuesQuery highlighting); #14756/#16175 (IndexOrDocValuesQuery counted twice, the opposite direction); #9855 (LUCENE-8811, the visitor-based cumulative clause count); #15097/#15124 (LRUQueryCache now sizes keys with RamUsageEstimator, which exposes the gap to the cache on main); #463 (open, stale: would add an Indri parser and an IndriOrQuery that inherits the same visit). Gaps: web search was throttled (7 completed); generic GitHub queries were read only to the first page.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: this doc's core and highlighter tests, the finder's and the independent reproducer's tests and upstream TestIndriAndQuery on stock, assertions on and off; the recommended fix on every org.apache.lucene.search test class plus TestRamUsageEstimator and the whole highlighter module, assertions on and off, and both modules' static checks; the Occur-based alternative on the repro classes, TestIndriAndQuery, TestQueryVisitor and TestMaxClauseLimit; the repro runner: the finder's, the verifiers', the adjudication's, the registry re-runs, the credibility audit and this doc's tests, several seeds, assertions on and off); the release jars 8.8.2 (no Indri classes), 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2; a 10.6.0-SNAPSHOT (branch_10_6) build at 4ae946e5a9b9 (the finder's JUnit with assertions off; the fact check's runs of this doc's core and highlighter tests, assertions on and off). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/IndriQuery.java:71-74 (visit: visitor.visitLeaf(this)); lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; QueryVisitor.java:54 (visitLeaf 'Called by leaf queries that do not match on terms'), :87 (termCollector 'collects all terms that may match a query'); IndexSearcher.java:1195 ('total clauses cumulatively in all of its children'); LRUQueryCache.java:470 (RamUsageEstimator.sizeOf(query, 32))
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/IndriQuery.java:28-96 (clauses field :30, visit :71-74, iterator :89-92, getClauses :94-96)
  • lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:33-73 (a Weight per clause :41-44, a scorer per clause that has one :47-58)
  • lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; QueryVisitor.java:53-58 (visitLeaf), :79-84 (getSubVisitor), :86-99 (termCollector)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:871-880 (rewrite runs the clause check), :891-932 (clause-count visitor; the check before the increment :908-930); BooleanQuery.java:82-84, 615-631
  • lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:319-357, 365-386; lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:465-471
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:108-111, 480-485, 502-529
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:72, 94 (default matches: MATCH_WITH_NO_TERMS)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:80 (maxClauseCount 1024), :82-84 (no default query cache), :976-983 (cache only without scores)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:908-930 (each counter call checks numClauses > maxClauseCount before ++numClauses); IndriAndWeight.java:67-73 (isCacheable, no clause cap)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; QueryVisitor.java:53-58, 86-91; IndexSearcher.java:1193-1199
  • lucene/CHANGES.txt:4664-4667 (LUCENE-8811), 5783 (LUCENE-8849), 6146-6147 (LUCENE-9050), 6452-6455 (LUCENE-3041)
  • lucene/core/src/test/org/apache/lucene/search/TestQueryVisitor.java:67-77; TestMaxClauseLimit.java:128-148; TestIndriAndQuery.java
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeVerifyQuery.java:33-41, 88-90; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:918 (terms extracted from the query as given)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/QueryVisitor.java:79-84 (MUST_NOT gets EMPTY_VISITOR); IndexSearcher.java:902-905
History
  • a Lucene clone tag releases/lucene/10.5.2 (IndriQuery.java visit unchanged)

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc from an executed probe on the stock main jars (assertions on and off, and with the fix). The PNG is the summary card.

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.