All findings · Lucene finding · LFV-944 · frequent-terms-span-rewrite-heap-sized-by-maxsize

FrequentTermsSpanBooleanQueryRewrite sizes its queue from maxSize before collecting any term, so Integer.MAX_VALUE fails every search and large caps cost heap on every rewrite

FrequentTermsSpanBooleanQueryRewrite, new in 10.6.0 (released 2026-10-29), reserves room for maxSize terms before it collects any. So Integer.MAX_VALUE as "no limit" makes every search throw IllegalArgumentException even when 3 terms match, and a cap of 2^26 costs about 268 MB of heap per query.

Summary

Priority
Low, rank 1127A loud crash at the Integer.MAX_VALUE boundary plus a large per-rewrite allocation in the new 10.6.0 FrequentTermsSpanBooleanQueryRewrite, a niche sibling of LFV-733's span top-N size problem, so it follows it.
Impact
crash
Since
new in 10.6.0, released 2026-10-29 (#16484, 2026-08-18)introduced 2026-08-18
Affected
10.6.0 (released 2026-10-29) and main; no earlier release
Status
documented
Found by
a size probe, then an exhaustive check against the javadoc and a Z3 model

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

SpanMultiTermQueryWrapper lets a prefix, wildcard or fuzzy query sit inside span queries by rewriting it into a SpanOrQuery of the matching terms. The new rewrite keeps only the maxSize most frequent of them, but it sizes its queue by maxSize instead of by the terms it finds.

Background: how a span multi-term rewrite keeps its terms

  1. SpanMultiTermQueryWrapper wraps a multi-term query such as PrefixQuery(f:aa). At search time a rewrite method expands it into a SpanOrQuery with one SpanTermQuery per kept term.
  2. FrequentTermsSpanBooleanQueryRewrite(maxSize), new in 10.6.0, keeps the maxSize terms with the highest document frequency. It first collects every matching term, then keeps the best maxSize in a priority queue.
  3. Lucene's internal PriorityQueue allocates its whole array, maxSize + 1 slots (2 for a size of 0), when it is created, and rejects sizes of 2,147,483,631 and above (Lucene's ArrayUtil.MAX_ARRAY_LENGTH, Integer.MAX_VALUE minus an array-header allowance).
Summary card: FrequentTermsSpanBooleanQueryRewrite sizes its queue by maxSize before collecting any term. Three documents with terms aa1, aa2, aa3 and PrefixQuery f:aa in a span wrapper: expected count 3; actual IllegalArgumentException 'maxSize must be >= 0 and < 2147483631; got: 2147483647'. maxSize 8 or 1024 and TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) give count 3; maxSize 67,108,864 gives count 3 but 268,485,504 bytes per rewrite; Integer.MAX_VALUE throws even with 0 matching terms. Why: ScoringRewrite.java:107 asks for the builder before collecting terms, and SpanMultiTermQueryWrapper.java:313 answers with PriorityQueue.usingComparator(maxSize, order). Fix: a lazy builder; with it every maxSize keeps the 3 terms and allocates 50,200 bytes per rewrite.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 3 documents with the terms aa1, aa2 and aa3. Wrap PrefixQuery(f:aa) in SpanMultiTermQueryWrapper and pick the new FrequentTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE), meaning "keep every matching term". The constructor accepts it.
  2. You expect a count of 3: the rewrite "keeps at most maxSize terms", and its sibling TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) returns 3 on the same index.
  3. Instead every search throws IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647, even when no term matches. The rewrite creates its priority queue with room for maxSize terms before it has collected a single term, and Lucene's queue refuses an array that large.
  4. A large cap that is legal pays on every query instead: maxSize 67,108,864 (2^26) allocates about 268 MB to keep 3 terms and runs out of memory with a 256 MB heap. The small caps tried (8 and 1,024) are fine.

Who is affected

Only applications that opt in to the new rewrite (10.6.0, released 2026-10-29, and main) with a huge maxSize: Integer.MAX_VALUE fails every query at once; a cap in the millions costs about 4 MB of heap per million on every rewrite. The small caps tried (8 and 1,024) work, and results are never wrong.

Mechanism, terms and numbers503 words

SpanMultiTermQueryWrapper.FrequentTermsSpanBooleanQueryRewrite, added for 10.6.0 by #16484, turns a span-wrapped multi-term query into a SpanOrQuery of the maxSize matching terms with the highest document frequency. It is built on ScoringRewrite, which collects every matching term first, but its builder is a PriorityQueue of capacity maxSize, and ScoringRewrite.rewrite creates the builder before it collects any term (ScoringRewrite.java:107 vs :109 on main 645d4fe14a20; the queue at SpanMultiTermQueryWrapper.java:313). Lucene's internal PriorityQueue allocates maxSize + 1 slots up front and rejects maxSize >= 2,147,483,631 (PriorityQueue.java:121-134). So Integer.MAX_VALUE, which the constructor accepts and the sibling TopTermsSpanBooleanQueryRewrite handles, makes every search throw IllegalArgumentException, with 3 matching terms or none, and a large legal cap such as 2^26 allocates about 268 MB on every rewrite. 10.6.0 (released 2026-10-29) and main are affected; no earlier release has the class. Sizing the queue by the terms actually collected fixes both and, in every case tested, leaves the kept terms unchanged.

The code (main 645d4fe14a20)

  1. The constructors store maxSize unchecked (SpanMultiTermQueryWrapper.java:301-308) and delegate to an anonymous ScoringRewrite<PriorityQueue<ScoreTerm>> whose builder is
   @Override
   protected PriorityQueue<ScoreTerm> getTopLevelBuilder() {
     return PriorityQueue.usingComparator(maxSize, order);
   }

(:311-314). addClause calls insertWithOverflow for each term (:331-339), build pops the queue into a SpanOrQuery (:316-324), and checkMaxClauseCount is a no-op because "the priority queue already bounds the number of retained terms" (:326-329).

  1. ScoringRewrite.rewrite calls getTopLevelBuilder() (ScoringRewrite.java:107) before collectTerms (:109). The number of matching terms is known only afterwards, col.terms.size() (:111).
  2. PriorityQueue.usingComparator builds new PriorityQueue<>(maxSize, lessThan) (PriorityQueue.java:61-64). The constructor throws IllegalArgumentException for maxSize < 0 or maxSize >= ArrayUtil.MAX_ARRAY_LENGTH (:121-125; Integer.MAX_VALUE - NUM_BYTES_ARRAY_HEADER, 2,147,483,631 on the test JVM), else allocates new Object[maxSize + 1] (:129, :134). The class is @lucene.internal and documents the pre-allocation only there (:34-39).

So the queue is sized by the cap, not by the terms found: at maxSize >= 2,147,483,631 every rewrite throws, and below that every rewrite with a positive maxSize allocates maxSize + 1 references (2 for maxSize 0) (4 bytes each with compressed references), for any number of matching terms.

The sibling

TopTermsRewrite.rewrite takes Math.min(size, getMaxSize()) and collects into a growing java.util.PriorityQueue (TopTermsRewrite.java:64-65). TopTermsSpanBooleanQueryRewrite uses it with getMaxSize() = Integer.MAX_VALUE (SpanMultiTermQueryWrapper.java:205-216), so it accepts Integer.MAX_VALUE and allocates the same small amount at every size tried.

The numbers (executed on main, 3 matching terms, assertions off)

rewritecountbytes allocated by one whole rewrite (thread allocation counter)
default (SCORING_SPAN_QUERY_REWRITE)350,000
TopTermsSpanBooleanQueryRewrite, every size tested (8 to Integer.MAX_VALUE)316,384
FrequentTermsSpanBooleanQueryRewrite(8)350,080
FrequentTermsSpanBooleanQueryRewrite(1,048,576)34,244,352
FrequentTermsSpanBooleanQueryRewrite(16,777,216)367,158,912
FrequentTermsSpanBooleanQueryRewrite(67,108,864)3268,485,504 (OutOfMemoryError with -Xmx256m)
FrequentTermsSpanBooleanQueryRewrite(2,147,483,631) or (Integer.MAX_VALUE)IllegalArgumentException-
FrequentTermsSpanBooleanQueryRewrite with the lazy fix, every size tested (8 to Integer.MAX_VALUE)350,200

The same exception with IndexSearcher.setMaxClauseCount(Integer.MAX_VALUE) and for a prefix that matches no term. Byte counts depend on the JVM; the edge 2,147,483,631 depends on its array header size.

Exposure and likelihood218 words
  • Opt-in. The wrapper's default rewrite is SCORING_SPAN_QUERY_REWRITE, or TopTermsSpanBooleanQueryRewrite for a top-terms query such as FuzzyQuery (SpanMultiTermQueryWrapper.java:65-77); an application must call setRewriteMethod(new FrequentTermsSpanBooleanQueryRewrite(n)). No Lucene code outside the class and its test builds it. Within that usage the default writer, codec and searcher suffice, and 3 documents are enough.
  • Loud, not silent. A huge cap fails on the first query with a clean IllegalArgumentException (the constructor accepted it). A cap in the millions costs maxSize + 1 slots of heap on every rewrite, up to OutOfMemoryError on a small heap. No wrong results, no index effect.
  • The clause limit. Under the default IndexSearcher clause limit (1024), keeping more than 1,025 terms already fails with TooManyNestedClauses (related bug LFV-733): the clause check tests numClauses > maxClauseCount before it counts the clause, so it lets 1,025 clauses through and throws on the 1,026th (IndexSearcher.java:907-921; the off-by-one is tracked upstream as #10339). So caps far above 1024 matter once the limit is raised. The Integer.MAX_VALUE failure does not depend on the clause limit.
  • Releases. The class first shipped in 10.6.0 (released 2026-10-29), which is affected; it can be fixed in 10.6.1.
  • Likelihood: rare. The class exists to bound expansion, so most callers pass small caps; a GitHub code search on 2026-10-28 found no downstream user.

Minimal repro

One JUnit test method in lucene/queries, public API: 3 documents in one segment, SpanMultiTermQueryWrapper(PrefixQuery f:aa) with FrequentTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE), counted on a plain IndexSearcher. It asserts a count of 3. Deterministic; the same result with assertions on and off.

TestFrequentTermsSpanRewriteMaxSize.java (lucene/queries, one test)55 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.queries.spans;

import java.util.List;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.spans.SpanMultiTermQueryWrapper.FrequentTermsSpanBooleanQueryRewrite;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.PrefixQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFrequentTermsSpanRewriteMaxSize extends LuceneTestCase {

  public void testMaxSizeIntegerMaxValue() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (String value : new String[] {"aa1", "aa2", "aa3"}) {
          w.addDocument(List.of(new TextField("f", value, Field.Store.NO)));
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        SpanMultiTermQueryWrapper<PrefixQuery> query =
            new SpanMultiTermQueryWrapper<>(new PrefixQuery(new Term("f", "aa")));
        query.setRewriteMethod(new FrequentTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE));
        try {
          assertEquals(3, new IndexSearcher(reader).count(query));
        } catch (IllegalArgumentException e) {
          // Expected: the 3 matching terms are kept and count is 3; actual: the rewrite throws.
          throw new AssertionError("expected count 3, but the rewrite threw " + e, e);
        }
      }
    }
  }
}
Command
cp $REPRO/TestFrequentTermsSpanRewriteMaxSize.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/spans/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFrequentTermsSpanRewriteMaxSize -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
3: the 3 matching terms are kept, as with TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE).
Observed
IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647 from the rewrite, with assertions on and off.

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

More files, environment, full output260 words

The test uses ByteBuffersDirectory and the default IndexWriterConfig, so the seed randomizes only test-framework settings (locale, time zone, default codec, similarity and the like), and the failure does not depend on them: it is the same with two seeds and with assertions on and off. The recorded gradle runs used this command in a scratch worktree with the test copied in, plus --no-daemon --max-workers=4 -Ptests.jvms=4. The catch only turns the exception into an expected-vs-actual message.

It asserts the headline (the exception) only. The per-rewrite allocation is a JVM-dependent consequence of the same line; the finder's allocation test measured it (about 268 MB at maxSize 2^26, OutOfMemoryError with -Xmx256m) and passes with the fix, but it is evidence, not a proposed regression test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the finder's test also fails on branch_10_6 4ae946e5a9b (10.6.0 release candidate)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestFrequentTermsSpanRewriteMaxSize.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/spans/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFrequentTermsSpanRewriteMaxSize -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestFrequentTermsSpanRewriteMaxSize > testMaxSizeIntegerMaxValue FAILED
    java.lang.AssertionError: expected count 3, but the rewrite threw java.lang.IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647
        at org.apache.lucene.queries.spans.TestFrequentTermsSpanRewriteMaxSize.testMaxSizeIntegerMaxValue(TestFrequentTermsSpanRewriteMaxSize.java:50)

        Caused by:
        java.lang.IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: 2147483647
            at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:123)
            at org.apache.lucene.util.PriorityQueue.<init>(PriorityQueue.java:80)
            at org.apache.lucene.util.PriorityQueue.usingComparator(PriorityQueue.java:63)
            at org.apache.lucene.queries.spans.SpanMultiTermQueryWrapper$FrequentTermsSpanBooleanQueryRewrite$1.getTopLevelBuilder(SpanMultiTermQueryWrapper.java:313)
            at org.apache.lucene.queries.spans.SpanMultiTermQueryWrapper$FrequentTermsSpanBooleanQueryRewrite$1.getTopLevelBuilder(SpanMultiTermQueryWrapper.java:310)
            at org.apache.lucene.search.ScoringRewrite.rewrite(ScoringRewrite.java:107)
            at org.apache.lucene.queries.spans.SpanMultiTermQueryWrapper$FrequentTermsSpanBooleanQueryRewrite.rewrite(SpanMultiTermQueryWrapper.java:350)
            at org.apache.lucene.queries.spans.SpanMultiTermQueryWrapper.rewrite(SpanMultiTermQueryWrapper.java:121)
            at org.apache.lucene.search.ConstantScoreQuery.rewrite(ConstantScoreQuery.java:44)
            at org.apache.lucene.search.IndexSearcher.rewrite(IndexSearcher.java:873)
            at org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:497)
            at org.apache.lucene.queries.spans.TestFrequentTermsSpanRewriteMaxSize.testMaxSizeIntegerMaxValue(TestFrequentTermsSpanRewriteMaxSize.java:47)
:lucene:queries:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false; with the fix: :lucene:queries:test (SUCCESS): 1 test

Why it looks unintended

  • The constructors promise only "keeps at most maxSize terms" (SpanMultiTermQueryWrapper.java:300); no bound is documented, and any int is accepted.
  • The class's own comment says it collects every term first; its sibling TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) works on the same index.
  • LUCENE-504 fixed this pattern in 2009, moving FuzzyQuery to a growing queue to support Integer.MAX_VALUE "without exhausting all memory".
The full argument169 words
  • Contract. "Create a rewrite that keeps at most maxSize terms" (SpanMultiTermQueryWrapper.java:300, :305). The class javadoc defines the class by its ranking difference to TopTermsSpanBooleanQueryRewrite (:261-263). The bound and the pre-allocation are documented only on the @lucene.internal PriorityQueue (PriorityQueue.java:34-39, :121-125).
  • The class's design. "ScoringRewrite first collects every matching term ... and this rewrite then retains the maxSize most frequent" (:268-274): the size of the set is known before the queue is needed.
  • The sibling. TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) keeps the 3 terms with the default and with a raised clause limit.
  • Precedent. LUCENE-504 (#1581), fixed by Uwe Schindler in 15743fc1793 (2009-11-06), changed FuzzyQuery to use java.util.PriorityQueue, "which grows dynamically to support BooleanQuery.maxClauseCount(Integer.MAX_VALUE) without exhausting all memory" (the commit subject itself spells the class java.utilPriorityQueue). TopTermsRewrite keeps that design today. IndexSearcher.searchAfter likewise caps numHits at Math.max(1, reader.maxDoc()) before sizing its collector (IndexSearcher.java:587-599).
  • Why tests missed it. The class's only upstream test uses size 8; #16484's review did not discuss sizes.
The devil's-advocate check186 words

Strongest case: pre-allocating a queue sized by the caller's N is a deliberate Lucene pattern (TopScoreDocCollectorManager and TopFieldCollectorManager document "pre-allocate a full array of length numHits"), and the PriorityQueue exception is a deliberate guard against a confusing OutOfMemoryError (4c646dab3fb, 2018). The class exists to bound expansion, so "no limit" goes against its purpose; SCORING_SPAN_QUERY_REWRITE already keeps every term; and under the default clause limit (1024) more than 1,025 terms fail anyway (the clause check throws on the 1,026th clause).

Why it fails: those collectors document the pre-allocation on their public API; this class does not, and fails late, at search time, from an internal class, after its public constructor accepted the value. The sibling it is defined against accepts Integer.MAX_VALUE; with a raised clause limit, keeping every matching term is a legal result; and LUCENE-504 already settled the question for multi-term rewrites. Even if huge caps were unsupported, the constructor should reject them. The intended-behaviour check: real, high confidence for the exception, medium for the allocation on its own, so the allocation is filed as a consequence, not as a second bug.

Suggested fix

Recommended A. Allocate the queue only when its size is known

Allocate the queue lazily (SpanMultiTermQueryWrapper.java, +57/-8): collect terms into a small list until more than maxSize arrive, then switch to a queue of maxSize; otherwise size the queue by the terms collected. Kept terms and their order are unchanged in every case tested. The test and all 513 lucene/queries tests pass.

void add(ScoreTerm st) {            // once per matching term
  if (queue == null) {
    if (pending.size() < maxSize) {
      pending.add(st);
      return;
    }
    queue = newQueue(maxSize);       // more than maxSize terms: allocate now
  }
  queue.insertWithOverflow(st);
}
// build(): finish() makes newQueue(pending.size()) if the queue was never needed
All options and trade-offs4 options · 685 words

Three fixes were built and run in a scratch worktree of 645d4fe14a20; capping or rejecting the size alone is not recommended.

A. Allocate the queue only when its size is known (validated by the checks) (recommended)

A private builder TopFrequentTerms replaces the queue as the ScoringRewrite builder. It keeps terms in a pending list while fewer than maxSize have arrived; on the first term beyond maxSize it allocates a queue of maxSize slots, moves the pending terms in and continues with insertWithOverflow; if that never happens, build() allocates a queue of exactly the collected count. Terms are offered in the same order as before. The constructor also rejects a negative maxSize (optional hardening; today a negative size fails at search time with the same exception).

void add(ScoreTerm st) {            // once per matching term
  if (queue == null) {
    if (pending.size() < maxSize) {
      pending.add(st);
      return;
    }
    queue = newQueue(maxSize);       // more than maxSize terms: allocate now
  }
  queue.insertWithOverflow(st);
}
// build(): finish() makes newQueue(pending.size()) if the queue was never needed

Trade-offs

About 50 lines and a new private class. When more than maxSize terms match, the peak at the switch is the pending list (maxSize terms, plus the ArrayList's spare capacity) plus the new queue (maxSize + 1 slots); the list is dropped right after the move. The branch_10_6 line reads new PriorityQueue<>(maxSize) { lessThan }, so a backport needs the 10.x queue API.

What the checks validated

  • Stock + test: fails with assertions on and off; + fix: passes with assertions on and off (gradle).
  • + fix: the whole lucene/queries module, 513 tests with the new test, 0 failures, assertions on and off; :lucene:queries:check -x test (format, ecjLint, forbiddenApis) passes.
  • + fix: the finder's 3 tests pass, including the 2^26 allocation test, also with -Xmx256m (stock: OutOfMemoryError).
  • The finder's exhaustive harness (0-4 matching terms): 60,960 cases, 0 mismatches with the fix and results identical to stock for every size stock accepts; a Z3 model: the fix cannot throw for maxSize >= 0 and its queue array never has more than max(n + 1, 2) slots (the model does not cover the pending list).
  • Not run: the full Lucene test suite, nightly tests, branch_10_6.

B. Collect into a list and size the queue in build() (validated by the checks)

The builder becomes a List<ScoreTerm>; build() creates PriorityQueue.usingComparator(Math.min(maxSize, builder.size()), order), offers every collected term with insertWithOverflow in the same order, then pops as before (+14/-12).

Trade-offs

Smallest and easiest to review, but it keeps a ScoreTerm and its Term for every matching term until build(), where today the queue drops the overflow at once. ScoringRewrite already holds every matching term's bytes and TermStates for the whole rewrite, so memory stays linear in the matching terms, with a larger constant when far more terms match than maxSize.

What the checks validated

The test passes; the whole lucene/queries module passes (513 tests); check -x test passes after a formatting fix; the finder's 3 tests and the -Xmx256m test pass.

C. A plus the clause-limit cap (validated by the checks)

Option A with builder capacity Math.min(maxSize, IndexSearcher.getMaxClauseCount()), as the core top-terms rewrites cap their size (+59/-8). Related bug LFV-733 changes the same line for the clause limit.

Trade-offs

It also changes LFV-733's case: an explicit maxSize above the clause limit then keeps at most the limit instead of failing with TooManyNestedClauses; argue that in LFV-733's issue.

What the checks validated

The whole lucene/queries module passes (513 tests); check -x test passes; the finder's 3 tests pass, including the raised-clause-limit case.

Not recommended alone: cap at the clause limit, or reject huge sizes in the constructor

Capping the eager queue at IndexSearcher.getMaxClauseCount() hides the bug only at the default limit: with setMaxClauseCount(Integer.MAX_VALUE) the exception is back, and the allocation becomes min(maxSize, limit) + 1 slots (executed by the finder and the independent reproduction). Rejecting large sizes in the constructor and documenting the pre-allocation fails fast, but keeps the per-rewrite allocation for every cap below the edge and diverges from the sibling, which accepts Integer.MAX_VALUE.

Downstream note

A GitHub code search on 2026-10-28 found no downstream user of the new rewrite; an application that takes maxSize from a request should cap it, as with any expansion limit.

How it was found

A planning probe compared the sizes the two span top-N rewrites accept and saw only the new one throw at Integer.MAX_VALUE. The finder reduced it to a public-API test, found the cause by reading ScoringRewrite, and checked the rewrite exhaustively on small indexes against a reference written from its javadoc.

The method in full172 words

Method: differential testing, then bounded-exhaustive translation validation and a Z3 model (semi-formal).

  • A planning probe on main a9779ef3c193 (2026-10-28) ran FrequentTermsSpanBooleanQueryRewrite and TopTermsSpanBooleanQueryRewrite at the same sizes: only the new rewrite threw, at 2,147,483,631 and Integer.MAX_VALUE, and it took 154 ms at 2^26 against 1 ms.
  • The finder reduced it to a public-API JUnit (6 documents, 3 terms) and found the cause by reading ScoringRewrite.rewrite while modelling it.
  • A harness compared the rewrite with a reference written from its javadoc on every small case: 60,960 cases, 0-4 matching terms, 1 or 2 segments, sizes -1, 0..n+1, 4096, 2,147,483,631 and Integer.MAX_VALUE, both built-in orders. The only mismatches were the 12,440 exception cases at the two edge sizes; a deliberately wrong reference is caught.
  • A Z3 bit-vector model of the queue sizing confirmed that the stock code can throw or allocate over 10^6 slots for at most 3 terms, and that the lazy fix cannot (6 of 6 queries as expected).
  • This doc's one-method test is a reduction of the finder's test.

Prior reports, history and checks

Prior reports

No prior report found: three automated search runs, 23 manual GitHub searches and 21 mailing-list searches on 2026-10-28 (GitHub issues and PRs, commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, downstream projects, the web), refreshed on 2026-10-29. Not fixed upstream.

Searches5 searches

Related, not reports of this bug: #16484 introduced the class (sizes never discussed; its only test uses size 8); #7571 (LUCENE-6513) is the feature request; #1581 (LUCENE-504, fixed 2009) is the same pre-sized-queue pattern in FuzzyQuery, a precedent at another fix site; 4c646dab3fb (2018) made PriorityQueue reject Integer.MAX_VALUE on purpose; #16532 is the 10.x compile fix of the same builder and kept the eager allocation; open #16851, #16277 and #13936 change PriorityQueue but keep the pre-sized heap. In-hunt: LFV-733 changes the same line for the clause limit (different root cause).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, Elasticsearch, OpenSearch, Solr)2026-10-28three automated runs (16 issue/PR queries each) and 23 manual searches: the class name, FrequentTerms, SpanMultiTermQueryWrapper limits, PriorityQueue with a huge size, the exception text; #16484, #16532 and #7571 read in fullonly #16484 (introduced the class) and unrelated items
Open apache/lucene PRs2026-10-28changed files of the PRs updated since 2026-10-14 or created since 2026-08-18none touches the file; three PriorityQueue PRs keep the eager allocation
Mailing lists and JIRA archive2026-10-2820 list queries per run and 21 direct searches (dev@, java-user@, issues@, commits@), all 31 dev@ mails of October 2026 about 10.6.0; 4 JIRA-archive queries per runno report; LUCENE-504 is the 2009 precedent
Downstream code and the web2026-10-28a GitHub code search; 15-20 web queries per runno downstream use of the class; nothing relevant
Refresh2026-10-29upstream branch heads; the file's commit list on main and branch_10_6; five keyword searches; items created since 2026-10-28; release tags and Maven Centralmain still 645d4fe14a20, the file unchanged, no new item, no 10.6.0 release

History

Introduced with the class by #16484 (156fb97fd33, committed 2026-08-18) and carried to 10.x, where #16532 rewrote the line but kept the eager sizing. Main and 10.6.0 (released 2026-10-29 from the tested release candidate) fail; no earlier release has the class (10.5.2 and 9.12.3 do not compile the test).

Versions tested6 rows

#16484 "LUCENE-6513: Add FrequentTermsSpanBooleanQueryRewrite" (David Smiley) is 156fb97fd33 on main; git log -S finds no earlier commit with the class or the line usingComparator(maxSize, order). On 10.x the cherry-pick 3572e2c03f0 was followed by 2bcbe3d82ed (#16532, Dawid Weiss, "Fix API only available on main"), which wrote new PriorityQueue<>(maxSize) { lessThan } instead. The older pieces are unchanged: ScoringRewrite has created its builder before collecting terms since at least 2015, PriorityQueue has rejected oversized queues since 2014 (3ae719d025d, SOLR-5661), and its current check and message, which also cover Integer.MAX_VALUE and negative sizes, date from 2018 (4c646dab3fb); and TopTermsRewrite has used a growing queue since 2010. The introducing commits themselves were not built; the relevant files are identical to the tested heads (except diamond operators on 10.x).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails, assertions on and off (gradle and standalone)
main a9779ef3c193 affectedthe finder's test fails 3 of 3, the independent reproduction's 4 of 6, assertions on and off
branch_10_6 4ae946e5a9b (10.6.0 RC1, released 2026-10-29) affectedthe finder's test fails 3 of 3; the independent reproduction's 4 of 6; OutOfMemoryError under -Xmx256m
branch_10x (10.7.0 development) read, not runthe same code (3572e2c03f0, 2bcbe3d82ed)
10.5.2, 9.12.3 and all earlier releases not affectedthe class does not exist: the test does not compile; the sibling control passes
main 645d4fe14a20 with fix A not affectedthe test and the 513 lucene/queries tests pass

Independent checks

The finder, an independent reproducer, a devil's-advocate verifier and an adjudication all rate it real; a cross-model refute review could not run code and agreed with the root cause. This doc's test was run on main through gradle, stock and with three fixes.

Checks and verdicts272 words

Every check except the cross-model reviews re-ran a repro on the stock jars; the reviewer from another model family (OpenAI GPT-5.6) only read code.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateJUnit fails 3 of 3 on main and branch_10_6 (-ea, -da); OutOfMemoryError under -Xmx256m; harness mismatches only at the two edge sizes; the lazy fix passes everything incl. 73 upstream span tests.
cross-model review partial overlap with LFV-733, not a duplicateSame line, different invariant; its wording points were addressed.
Independent reproducer realOwn JUnit: 4 of 6 fail on main and branch_10_6, controls pass; FrequentTerms(2^24) about 67 MB vs about 7 to 11 KB for TopTerms(2^24); LFV-733's clamp alone leaves the raised-limit test failing.
Cross-model refute review not real, low, procedural onlyCould not run any command, called that "not a substantive refutation", agreed with the root cause; a separate re-run failed 3 of 3.
Devil's advocate realHigh confidence for the exception, medium for the allocation alone; the LUCENE-504 precedent; re-runs fail.
adjudication confirmed, highRe-ran the independent reproduction's test (4 of 6) and the finder's (3 of 3) on main with new seeds; duplicate of none.
Registration check and credibility audit keepRe-ran the finder's test on main with -da and two seeds: 3 of 3 each.
this doc as aboveThe one-method test through gradle on 645d4fe14a20 (-ea, -da); fixes A, B and C: the test, the whole lucene/queries module, check -x test, the finder's tests and the -Xmx256m test.

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

Finding record

LFV-944, snapshot of 2026-11-06

Registry title
SpanMultiTermQueryWrapper.FrequentTermsSpanBooleanQueryRewrite (new in 10.6.0) sizes its priority queue from maxSize before collecting any term, so maxSize >= 2147483631 (for example Integer.MAX_VALUE as 'no limit') makes every rewrite throw IllegalArgumentException, and a large maxSize allocates a (maxSize + 1)-slot array on every rewrite
Area
queries / spans (SpanMultiTermQueryWrapper.FrequentTermsSpanBooleanQueryRewrite queue sizing; core ScoringRewrite creates the top-level builder before collecting terms; util PriorityQueue pre-allocates maxSize + 1 slots)
Priority
Low, rank 1127: A loud crash at the Integer.MAX_VALUE boundary plus a large per-rewrite allocation in the new 10.6.0 FrequentTermsSpanBooleanQueryRewrite, a niche sibling of LFV-733's span top-N size problem, so it follows it.
Impact class
crash
Impact note
Applications that install the new (10.6.0) FrequentTermsSpanBooleanQueryRewrite on a SpanMultiTermQueryWrapper with a very large maxSize are hit: Integer.MAX_VALUE (a natural 'no limit' value that the sibling TopTermsSpanBooleanQueryRewrite accepts) or any value >= 2147483631 makes every search or rewrite of that query throw IllegalArgumentException from the internal PriorityQueue, even on a field with 3 matching terms and with IndexSearcher.setMaxClauseCount raised, and a large legal cap such as 2^24 or 2^26 makes each rewrite allocate a (maxSize + 1)-slot array (about 67 MB or 268 MB on the test JVM, OutOfMemoryError under -Xmx256m) whatever the number of matching terms; default writer, codec and searcher, 6 documents suffice, small caps (8-1024) are unaffected, and only 10.6.0 (released 2026-10-29) and main have it.
Summary
You wrap a PrefixQuery in SpanMultiTermQueryWrapper, call setRewriteMethod(new FrequentTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE)), whose javadoc promises only that it 'keeps at most maxSize terms' (ranked by document frequency instead of boost, unlike TopTermsSpanBooleanQueryRewrite), and expect it to keep all 3 matching terms, as TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) does on the same index. Instead the constructor accepts the value and every search then throws IllegalArgumentException 'maxSize must be >= 0 and < 2147483631; got: 2147483647', because getTopLevelBuilder() returns PriorityQueue.usingComparator(maxSize, order) and ScoringRewrite.rewrite calls it before it collects any term, so the internal queue pre-allocates maxSize + 1 slots (or rejects a size it cannot allocate) instead of being sized by the terms actually found; for the same reason a large legal cap such as 2^26 allocates about 268 MB per rewrite for 3 terms. Sizing the queue lazily, by min(maxSize, collected terms), as the sibling TopTermsRewrite does with a growing queue, fixes both and leaves every result unchanged.
Introduced
156fb97fd33d8f2039309381ccaca4a6a7e59e14 on main, 'LUCENE-6513: Add FrequentTermsSpanBooleanQueryRewrite' (apache/lucene#16484, author dsmiley; the feature request is #7571): it added the class with getTopLevelBuilder() { return PriorityQueue.usingComparator(maxSize, order); }, so the defect was born with the API [read: git log -S FrequentTermsSpanBooleanQueryRewrite --all and git log -S 'usingComparator(maxSize, order)' return only this commit; git blame of SpanMultiTermQueryWrapper.java:300-315 on a9779ef3c193]. On branch_10x and branch_10_6: the cherry-pick 3572e2c03f0 plus 2bcbe3d82ed (#16532, dweiss, 'Fix API only available on main'), which replaced usingComparator with new PriorityQueue<>(maxSize) { lessThan } and kept the eager sizing. The older ingredients are unchanged: ScoringRewrite has created its builder before collecting terms since at least 2015, PriorityQueue has pre-allocated maxSize + 1 slots and rejected sizes >= ArrayUtil.MAX_ARRAY_LENGTH since at least 2018 (4c646dab3fb), and TopTermsRewrite has used Math.min(size, getMaxSize()) with a growing queue since 2010. The introducing commits were not built (disk rule); git diff 156fb97fd33..a9779ef3c193 is empty for the five relevant files, and 2bcbe3d82ed..4ae946e5a9b differs only by diamond-operator edits (#16592) [read] (2026-08-18 (author date of 156fb97fd33 and of the cherry-pick 3572e2c03f0, 12:02:08-04:00; #16484 merged 16:02:09Z); 2026-08-19 (2bcbe3d82ed, the 10.x API adaptation))
First affected version
10.6.0 (released 2026-10-29; the release revision branch_10_6 4ae946e5a9b fails the repro [exec]). No earlier release is affected: 10.5.2 and 9.12.3 do not have the class [exec: compile error, 6 x 'cannot find symbol FrequentTermsSpanBooleanQueryRewrite']
Affected versions
main a9779ef3c193 (11.0.0-SNAPSHOT) [exec: the finder's JUnit fails 3 of 3 with -ea and -da (seed DEADBEEF); the independent reproduction's own JUnit fails 4 of 6 with -ea and -da, its 2 controls pass; the intended-behaviour check re-ran the finder's JUnit with -ea and -da (3 of 3); the adjudication re-ran the independent reproduction's (-ea, seed 5EED378, 4 of 6) and the finder's (-da, seed 5EED379, 3 of 3); the registration check re-ran the finder's with -da (seed 5EED3781, 3 of 3); FrequentTerms(2^26) throws OutOfMemoryError under -Xmx256m on a 3-term field while TopTerms(2^26) passes in the same JVM]. branch_10_6 head 4ae946e5a9b (the 10.6.0 RC1 revision, released as 10.6.0 on 2026-10-29) [exec: finder 3 of 3 with -ea and -da; the independent reproduction 4 of 6 with -ea and -da plus the -Xmx256m OutOfMemoryError; the intended-behaviour check 3 of 3 with -da]. branch_10x (10.7.0 development; contains 3572e2c03f0 and 2bcbe3d82ed) [read, not executed]. Not affected: every release up to 10.5.2, all 9.x included (class absent; 10.5.2 and 9.12.3 executed: compile error, while the sibling control TopTermsSpanBooleanQueryRewrite(Integer.MAX_VALUE) passes 2 of 2 there). Not fixed upstream: SpanMultiTermQueryWrapper.java last changed on main by 156fb97fd33 and on branch_10_6 by f6fce7e42c5 (#16592, build infrastructure) [read: GitHub commits API, 2026-10-28, upstream main head 645d4fe14a20]. All runs on Linux aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by exhaustive check, code reading, Z3
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only): three scripted prior-report searches (each 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 15-20 web queries; 132, 212 and 133 distinct hits) plus the finder's earlier run (51 queries, re-read); 23 manual GitHub issue/PR searches over apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub; a GitHub code search (no downstream use of the class); the changed-file lists of the open apache/lucene PRs updated since 2026-10-14 or created since 2026-08-18; PR #16484 (body, review, comments), #16532 and all comments of #7571; 21 direct mailing-list searches and all 31 dev@ mails of October 2026 about the 10.6.0 release (through the RC1 vote). Nearest items, not this bug: #16484 (introduced the class; sizes never discussed; its only test uses size 8), #7571 (LUCENE-6513, the open feature request), #1581 (LUCENE-504, fixed 2009: the same pre-sized PriorityQueue pattern in FuzzyQuery, a precedent at a different fix site), 4c646dab3fb (2018, the deliberate PriorityQueue IllegalArgumentException), #16532 (the 10.x compile fix, which kept the eager allocation), open #16851, #16277 and #13936 (PriorityQueue changes that keep the pre-sized heap). Not fixed upstream: SpanMultiTermQueryWrapper.java unchanged since #16484, #16532 and #16592 on main 645d4fe14a20, branch_10_6 4ae946e5a9b and branch_10x; refresh 2026-10-28 through the GitHub REST API (the search API was rate-limited at that moment): no new commit on the file, and the only issue or PR created after a recent cutoff (#16937) does not mention spans.
Lucene commit tested
main 645d4fe14a20 (this doc's test through gradle and the standalone runner, assertions on and off; fixes A, B and C in a scratch worktree) and a9779ef3c193 (the hunt; the relevant files are identical); the 10.6.0 release-candidate head branch_10_6 4ae946e5a9b (the finder's, the independent reproduction's and the intended-behaviour check's tests); the 10.5.2 and 9.12.3 release jars (the class is absent: compile error).

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanMultiTermQueryWrapper.java:257-263 (class javadoc), :300-308 (constructors, maxSize unchecked), :310-314 (getTopLevelBuilder)
  • lucene/core/src/java/org/apache/lucene/search/ScoringRewrite.java:104-111 (getTopLevelBuilder before collectTerms; size known only afterwards)
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-39 (@lucene.internal, pre-allocation NOTE), :61-63 (usingComparator), :117-134 (IllegalArgumentException for maxSize >= ArrayUtil.MAX_ARRAY_LENGTH, new Object[maxSize + 1])
  • lucene/core/src/java/org/apache/lucene/search/TopTermsRewrite.java:61-65 (sibling: Math.min(size, getMaxSize()) and a growing java.util.PriorityQueue)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/spans/SpanMultiTermQueryWrapper.java:257-274 (class javadoc and implementation comment), :300-340 (constructors, builder, build, addClause), :205-216 (TopTerms sibling)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ScoringRewrite.java:104-124
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-39, :61-81, :113-134
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopTermsRewrite.java:61-65
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:33-34
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/spans/SpanMultiTermQueryWrapper.java:65-77 (default rewrite selection)
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:519-521 (10.6.0 section)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/spans/SpanMultiTermQueryWrapper.java:257-274, :300-308
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:34-39, :121-125
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:587-599
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:45; TopFieldCollectorManager.java:44
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/ScoringRewrite.java:108-111, :126-129 (ScoringRewrite holds every collected term)

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

Repro files

About the diagrams

It is made in parallel with this doc; every on-screen value comes from the code at 645d4fe14a20 or the diagram's executed probe (stock jars and the lazy fix).

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.