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.
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.- Build an
IndriAndQueryof two term clauses,body:presidentandbody:washington, and search a one-document index ("president washington was the first leader"): 1 hit, the same as theBooleanQueryover the same clauses. - Ask which terms it may match with
QueryVisitor.termCollector. You expect {body:president, body:washington}, as for theBooleanQuery. You get []. The UnifiedHighlighter in term-extraction mode uses the same collector and returns the passage with no<b>marks. - Set
maxClauseCountto 4: aDisjunctionMaxQueryand a nestedBooleanQueryover 6 term clauses throwTooManyClauses. AnIndriAndQueryof the same 6 clauses runs (1 hit), and so does aBooleanQueryof two 3-clauseIndriAndQuerys. With a limit of 16, a 2,000-clauseIndriAndQueryruns too. - Why:
IndriQuery.visitisvisitor.visitLeaf(this), so the visitor is told "leaf" and never sees the clauses, althoughQuery#visitmust "recurse through the query tree, visiting any child queries". The same root makesRamUsageEstimator.sizeOfreturn 1,024 bytes for 1 clause and for 1,000 (BooleanQuery: 1,169,024).
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.
QueryVisitor: Lucene's query-introspection API.Query#visitis documented to "Recurse through the query tree, visiting any child queries". A query with children pulls a sub-visitor withgetSubVisitor(occur, this)and visits each child with it; a query that matches on terms callsconsumeTerms;visitLeafis 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 byIndexSearcher.rewrite),RamUsageEstimator.sizeOf(Query), and on main theLRUQueryCachekey accounting that uses it. IndriAndQuery: a core query (since 8.9.0, LUCENE-9537) built from aList<BooleanClause>; its base classIndriQuerykeeps the list, andIndriAndWeightbuilds aWeightfor every clause and, per segment, a scorer for each clause that has one.
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:
termCollectorcollects only fromconsumeTerms, andvisitLeafis a no-op: it returns [].- The UnifiedHighlighter's
extractTermsisquery.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. IndexSearcher.rewriteruns a clause-counting visitor that adds one pervisitLeaf,consumeTermsorconsumeTermsMatchingcall, so a wholeIndriAndQuerycounts as one clause. Neither Indri class overridesrewrite, and theIndriAndQueryconstructor has no size check (BooleanQuery.Builder.addrefuses more thanmaxClauseCountclauses at build time).RamUsageEstimator.sizeOf(Query)starts at the default size for the root and adds nothing for a root that callsvisitLeaf, 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).
| tool | IndriAndQuery | BooleanQuery / 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 clauses | IndriAndQuery(6) and BooleanQuery(IndriAndQuery(3), IndriAndQuery(3)) run, 1 hit | DisjunctionMaxQuery(6) and nested BooleanQuery(3+3) throw |
maxClauseCount=16, 2,000 clauses (security probe) | runs, 60,000+ hits | BooleanQuery.Builder throws while adding |
RamUsageEstimator.sizeOf | 1,024 bytes for 1 clause and for 1,000 | 1,169,024 bytes for the same 1,000 |
LRUQueryCache key accounting, main only (independent reproducer) | 192 bytes for 16 clauses and for 500 | 3,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 wholelucene/tree no parser or builder produces one (onlyIndriAndQuery.javaitself,IndriAndWeight.javaandTestIndriAndQuery.javamention 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
IndriAndQueryas one clause, at any nesting depth, so it only matters once counting the clauses would trip the limit: more thanmaxClauseCount + 1calls (default 1,025), since the counter checks before it increments. AQueryTimeoutstill stops a large query. - RAM estimate.
RamUsageEstimator.sizeOfignores the clauses in every release since 8.9.0; on main (unreleased, #15124)LRUQueryCachesizes 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-clauseIndriAndQuery: 192 bytes against 3,136 with the fix; 500 clauses: 192 against 92,192; independent reproducer). - How likely.
IndriAndQueryis a rarely used API, and the visible symptoms are in tools that inspect the query, not in search results.
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.
/*
* 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);
}
}
# 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.termCollectoroverIndriAndQuery(f:a, f:b)should collect[f:a, f:b], as it does for a BooleanQuery of the same clauses; actual[], with-eaand-da.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)Query#visitmust "recurse through the query tree, visiting any child queries";IndriAndQueryholds 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
visiton each of their sub queries with it. AndvisitLeafis for queries that don't match on terms." - Tests.
TestQueryVisitor.testExtractTermsEquivalentasserts thattermCollectorreaches terms inside nestedBooleanQueryandBoostQuerycomposites;TestMaxClauseLimit.testLargeDisjunctionMaxQueryasserts that a non-Boolean composite with 1,050 children throwsTooManyNestedClauses.TestIndriAndQuerychecks 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 (onbranch_8x, which shipped 8.9.0,Query#visitthen still defaulted to the samevisitLeaf(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.
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.
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
IndriAndQuerywhose clauses take the cumulative count over the limit now throwsTooManyNestedClauses, like other composites. The counter checks before it increments, so at the default 1,024 anIndriAndQueryof 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.sizeOfand, on main, the query cache now account for the clauses (a cached 16-clauseIndriAndQuery: 3,136 bytes, equal to theBooleanQuery; 500 clauses: 92,192).- Visitors now also receive callbacks from clauses that need rewriting (for example a
PrefixQuery). Searching such a query already fails atcreateWeight, becauseIndriAndQuerydoes not rewrite its clauses (a related finding), but visitor-only consumers (term extraction, highlighting,sizeOf, custom visitors) never callcreateWeight, so for them these callbacks are new. - Does not fix the blank highlights in the default weight-matches mode, which needs
Weight#matchesinIndriAndWeight. - 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 oforg.apache.lucene.searchplusutil.TestRamUsageEstimator, with the 3 repro classes: 218 classes, 2,353 tests, 0 failures, with assertions on and off (TestMaxClauseLimit,TestQueryVisitor,TestLRUQueryCache,TestBooleanQuery,TestIndriAndQueryamong them). - Whole
:lucene:highlighterwith the highlighter repro: 35 classes, 723 tests, 0 failures, assertions on and off. :lucene:core:check -x testand:lucene:highlighter:check -x testwith 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.
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.
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.
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.
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.
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.
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
Queryclass in the stock main jars (JDK ClassFile API) for contract checks; its rows VISIT-MISS and VISIT-NOSUB flagged thatIndriAndQuery'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 (
termCollectorreturns []). The finder built a bounded-exhaustive harness: every clause list over 3 terms and 4Occurvalues (1,884 queries), run through the real jar and compared with the same clauses in aBooleanQueryand with a Z3 model of the expected visitor results.termCollectordiffered in 1,845 lists and the clause-count visitor in 1,860, with 0 mismatches against the model's prediction for the leaf-onlyvisit. - 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr) | 2026-10-28 | 3 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, TooManyClauses | no report |
| Threads read in full | 2026-10-28 | lucene-solr#2097 (all review comments, all 24 commits), #10577, #11194, #11290, #463, #12295, #9855, #15097, #15124 | no mention of visit; #2097's one design question (r532221029) is about which clause types are supported |
| Open and recent PRs | 2026-10-28 | changed 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 JIRA | 2026-10-28 | dev@, 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 web | 2026-10-28 | Elasticsearch, OpenSearch, Solr trackers; the Lucindri repositories; 7 web searches (several throttled) | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | every 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 | affected | the 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 | affected | this doc's test fails 3/3; highlighter test fails on 10.5.2 |
| 9.12.3, 9.1.0, 9.0.0 | affected | term 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 limit | term collection and sizeOf fail; the cumulative clause limit does not exist before 9.0.0 |
| 8.8.2 | not affected | no Indri classes |
| fix (main + one-method visit) | not affected | every repro passes; core search and highlighter suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| independent reproducer | real | Own 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, medium | Read 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 | real | Built 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, high | With 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 | keep | Re-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 validated | Gradle on stock and with the fix (search package, highlighter module, static checks), assertions on and off. |
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 418 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words
- 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 upstreamTestIndriAndQueryon stock, assertions on and off; the recommended fix on everyorg.apache.lucene.searchtest class plusTestRamUsageEstimatorand the whole highlighter module, assertions on and off, and both modules' static checks; theOccur-based alternative on the repro classes,TestIndriAndQuery,TestQueryVisitorandTestMaxClauseLimit; 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 at4ae946e5a9b9(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.
- 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-631lucene/core/src/java/org/apache/lucene/util/RamUsageEstimator.java:319-357, 365-386; lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:465-471lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:108-111, 480-485, 502-529lucene/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-1199lucene/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.javalucene/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)
- TestIndriAndQueryVisit.java (JUnit test):
repro/LFV-872/src/org/apache/lucene/search/TestIndriAndQueryVisit.java(41 lines)
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.
assets/LFV-872/bug.png(418 KB)
