All findings · Lucene finding · LFV-081 · lru-min-segment-size-overflow

LRUQueryCache's MinSegmentSizePredicate overflows maxDoc * 2, so segments of 2^30+ docs are never cached

LRUQueryCache's default rule for which segments to cache doubles the segment's document count in int arithmetic, which overflows from 2^30 (1,073,741,824) documents on, so such a segment is never cached, however large the cache. Results stay correct; only the caching is lost, and the cache statistics do not show it.

Summary

Priority
Low, rank 1034LRUQueryCache's MinSegmentSizePredicate overflows so segments of 2^30 or more docs are never cached, performance only at extreme scale with a large non-default cache.
Impact
minor
Since
9.6.0 (#12147, commit 8e15c665be9, 2023-02-16)introduced 2023-02-16 · about 3 years
Affected
9.6.0-10.6.0 and main
Status
documented
Found by
non-formal: noticed in code reading, then executed tests

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: how the query cache picks segments

  1. LRUQueryCache remembers, per segment, which documents a filter query matched, so repeating the query on that segment is a lookup instead of a search.
  2. Caching costs memory, so it decides per segment. It skips the whole index if five bit sets of its size would not fit its share of the RAM budget, and it skips segments that are small compared with the others.
  3. With new LRUQueryCache(maxSize, maxRamBytesUsed) the second rule is MinSegmentSizePredicate(10000): cache a segment with at least 10,000 documents and more than half the average number of documents per segment.
Diagram of the bug: an index with one segment of 1,073,741,824 (2^30) documents and LRUQueryCache's check "maxDoc * 2 > average". In int arithmetic 1,073,741,824 x 2 wraps to -2,147,483,648, which is not greater than the average 1,073,741,824, so the segment is never cached (cacheCount 0, hitCount 0). With 1,073,741,823 documents the product is 2,147,483,646, the check passes and the segment is cached. The fix 2L * maxDoc computes 2,147,483,648 in long.
A single segment of 1,073,741,824 documents: doubling it in int gives -2,147,483,648, which is not more than the average, so the predicate says no and the segment is never cached; one document fewer gives 2,147,483,646 and is cached. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Put 1,073,741,824 (2^30) documents in one segment, for example with forceMerge(1) or IndexWriter.addIndexes, and enable new LRUQueryCache(100, 64 GiB) with a caching policy that caches every query.
  2. You expect the segment to be cached: it has more than 10,000 documents, and as the only segment it is the average, more than the required half of it. The second search should be a cache hit.
  3. Instead the check computes maxDoc * 2 as an int: 1,073,741,824 x 2 = 2,147,483,648 does not fit (the maximum is 2,147,483,647), wraps to -2,147,483,648, and that is not more than 1,073,741,824. The answer is no.
  4. So the segment is never cached: after two searches cacheCount=0 hitCount=0 missCount=0. With one document fewer, 1,073,741,823, the same searches give cacheCount=1 hitCount=1. Every segment from 2^30 documents up to the 2,147,483,519 limit is affected.

Who is affected

Users who enable caching with new LRUQueryCache(maxSize, maxRamBytesUsed) and have one segment of 2^30 or more documents, for example after forceMerge(1) of a 1.1 billion document index. The defaults hide it: the budget must admit such an index at all, over 640 MiB on 9.6.0-10.6.0 and over 10 GiB on main.

Mechanism, terms and numbers511 words

LRUQueryCache(int maxSize, long maxRamBytesUsed) installs MinSegmentSizePredicate(10000) as its rule for which segments to cache. Its javadoc says items are cached on leaves that have more than 10k documents and more than half of the average documents per leaf, which "should guarantee that all leaves from the upper TieredMergePolicy tier will be cached". The predicate checks "more than half of the average" as return maxDoc * 2 > averageTotalDocs; with maxDoc an int. For every segment of 2^30 documents or more (a real segment holds at most IndexWriter.MAX_DOCS = 2,147,483,519) the product is negative, so the comparison is false whatever the average, and the segment is never cached, however large the cache. The rule came with #12147 (9.6.0), the fix for #12140, where the largest segments of an index were never cached; the overflow brings that symptom back for the very largest segments. Search results stay correct, and the segment is counted neither as a cache hit nor as a miss. The fix is 2L * maxDoc.

Terms

  • Leaf / segment: a part of the index with its own documents, numbered 0 to maxDoc() - 1. An int holds at most 2,147,483,647; IndexWriter.MAX_DOCS is 2,147,483,519.
  • leavesToCache: the Predicate<LeafReaderContext> that LRUQueryCache asks before it caches anything on a segment. The two-argument constructor uses MinSegmentSizePredicate(10000); the four- and six-argument constructors take your own.
  • RAM gate: before the predicate, the cache checks that five dense bit sets for the whole index (maxDoc / 8 bytes each) fit in the budget (on main, in a sixteenth of it, one of 16 partitions).

The bug

  1. MinSegmentSizePredicate.test reads the segment's maxDoc as an int, returns false below 10,000, computes averageTotalDocs = topLevel.maxDoc() / leaves.size(), and returns maxDoc * 2 > averageTotalDocs (LRUQueryCache.java:287-294 on main).
  2. For maxDoc from 2^30 on (up to MAX_DOCS for a real segment; any int up to 2^31 - 1 behaves the same) the product 2 * maxDoc is at least 2^31 and wraps to 2 * maxDoc - 2^32, between -2,147,483,648 and -2. The average is never negative, so the answer is always false, whatever the other segments are. It never errs the other way.
  3. CachingWrapperWeight.shouldCache is the RAM gate && this predicate (:804-808). scorerSupplier and count check it (:823, :910) and, when it is false, run the query uncached, before the cache lookup and before the caching policy is asked (:834, :838). No policy can override it, and since no lookup happens, no miss is counted.

The numbers

One real segment of exactly 1,073,741,824 documents, built through the public API, LRUQueryCache(100, 64 GiB), an always-cache policy, ConstantScoreQuery(TermQuery) searched twice: 256 of 256 hits (correct) and cacheCount=0 hitCount=0 missCount=0, on main and on 10.5.2, with assertions on and off. With 1,073,741,823 documents (one of the 256 copies has one document fewer): cacheCount=1 hitCount=1 missCount=1. With 2L * maxDoc: cacheCount=1 hitCount=1. Driven directly, the predicate accepts a single segment of 10,000, 2^29 and 2^30 - 1 documents and rejects 2^30, 2^30 + 1, 2,000,000,000 and 2,147,483,519.

Exposure and likelihood245 words
  • Who is hit. Users who enable query caching with LRUQueryCache(int, long), the form in the class javadoc example and in MIGRATE.md ("To enable caching back ..."), and have a segment of 2^30 or more documents. Every otherwise cache-eligible query runs uncached on that segment, on every search: cached filters never help on exactly the biggest, most expensive segment.
  • Default vs opt-in. Main has no default query cache (GITHUB#14187). In 9.6.0-10.6.0 the default is LRUQueryCache(1000, min(32 MiB, heap / 20)), whose RAM gate already refuses any index above 53,687,095 documents. So it takes an opt-in cache with a large budget: for an index of exactly 2^30 documents more than 671,088,640 bytes (640 MiB) on 9.6.0-10.6.0, and at least 10,737,418,256 bytes (just over 10 GiB) on main, where each of 16 partitions gets a sixteenth. Bigger indexes need more.
  • How such a segment arises. forceMerge(1) of a large index (maxNumSegments wins over the merge policy's size cap), addIndexes(CodecReader...) (one merged segment by default), a raised setMaxMergedSegmentMB, or a custom merge policy. The default 5 GiB cap is in bytes, so it only stops this for documents averaging more than about 5 bytes on disk. Lucene's own @Monster test TestIndexWriterMaxDocs.testExactlyAtTrueLimit force-merges MAX_DOCS documents into one segment.
  • Visibility. None: no exception, and the segment adds nothing to the hit, miss and cache counts (they count the whole cache; in the fresh-cache runs all three stay 0).
  • Not affected. Caches built with their own leavesToCache predicate.

Minimal repro

One JUnit test in org.apache.lucene.search, because the predicate is package-private (upstream TestLRUQueryCache does the same). A FilterLeafReader over a real one-document segment reports maxDoc() = 2^30; the predicate reads nothing else, so this answers like a real 2^30-document segment. The result does not depend on the seed; the test itself runs in about 0.15 s.

TestMinSegmentSizePredicateOverflow.java (JUnit, one test)62 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.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FilterLeafReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMinSegmentSizePredicateOverflow extends LuceneTestCase {

  public void testSingleSegmentOf2Pow30Docs() throws IOException {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(new Document());
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        LeafReader leaf =
            new FilterLeafReader(reader.leaves().get(0).reader()) {
              @Override
              public int maxDoc() {
                return 1 << 30;
              }

              @Override
              public CacheHelper getCoreCacheHelper() {
                return null;
              }

              @Override
              public CacheHelper getReaderCacheHelper() {
                return null;
              }
            };
        assertTrue(
            "expected true for the only segment (2^30 docs >= 10000 and > half the average 2^30),"
                + " got false",
            new LRUQueryCache.MinSegmentSizePredicate(10000).test(leaf.getContext()));
      }
    }
  }
}
Command
cp TestMinSegmentSizePredicateOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestMinSegmentSizePredicateOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
MinSegmentSizePredicate(10000).test(...) returns true for the only segment (2^30 documents, average 2^30).
Observed
It returns false, because 2^30 * 2 wraps to -2^31 in int; the same with assertions off.

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

More files, environment, full output260 words

The predicate reads only the leaf's maxDoc(), the top-level reader's maxDoc() and the number of leaves (LRUQueryCache.java:286-294). The wrapped leaf is its own top-level reader, so the average is 2^30: the smallest single-segment input that wraps. The two cache helpers return null only because FilterLeafReader requires them. The same answer was checked on a real single segment of 2^30 documents, built with IndexWriter.addIndexes(CodecReader...) of 256 copies of a 2^22-document segment and searched through LRUQueryCache(100, 64 GiB) (about 6 s and 15 MB of disk), on main and 10.5.2; that probe is evidence, not the proposed test.

As the regression test: the same test, for example as a new method in TestLRUQueryCache.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 9.6.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0; 9.5.0 not affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestMinSegmentSizePredicateOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestMinSegmentSizePredicateOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestMinSegmentSizePredicateOverflow > testSingleSegmentOf2Pow30Docs FAILED
    java.lang.AssertionError: expected true for the only segment (2^30 docs >= 10000 and > half the average 2^30), got false
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:211FF782ABBD45E1]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.search.TestMinSegmentSizePredicateOverflow.testSingleSegmentOf2Pow30Docs(TestMinSegmentSizePredicateOverflow.java:55)

# with `return 2L * maxDoc > averageTotalDocs;`: the test passes (assertions on and off)

# real single segment, LRUQueryCache(100, 64 GiB), always-cache policy, ConstantScoreQuery searched twice
maxDoc=1073741824 (2^30):      hits=256/256 cacheCount=0 hitCount=0 missCount=0   (main and 10.5.2, -ea and -da)
maxDoc=1073741823 (2^30 - 1):  hits=256/256 cacheCount=1 hitCount=1 missCount=1
maxDoc=1073741824 with 2L fix: hits=256/256 cacheCount=1 hitCount=1 missCount=1

Why it looks unintended

  • The javadoc says the rule "should guarantee that all leaves from the upper TieredMergePolicy tier will be cached"; a lone 2^30-document segment is that tier.
  • The rule came from #12147, the fix for #12140, where the largest segments were never cached.
  • The float-ratio rule it replaced had no cutoff, and 2L * maxDoc restores caching.
The full argument198 words
  • Javadoc of LRUQueryCache(int, long) (LRUQueryCache.java:243-250): "Items will only be cached on leaves that have more than 10k documents and have more than half of the average documents per leave of the index. This should guarantee that all leaves from the upper TieredMergePolicy tier will be cached." A single 2^30-document segment meets both conditions and is the upper tier.
  • History. #12140 (type:bug, by dnhatn) reported an index of 34 segments of 5 GB that was never cached under the old 3% rule. In that thread jpountz proposed the current rule: "maybe it should even be half the average segment size to make sure it includes all segments from the upper tier?" #12147 (type:bug) implemented it; its commit message: "This change adjusts the cache policy to ensure that all segments in the max tier to be cached." Nothing there excludes very large segments.
  • The old rule had no cutoff. 9.5.0 compared (float) maxDoc / topLevelMaxDoc with .03f, which cannot overflow.
  • No documented limit. Nothing in the class, MIGRATE.md or CHANGES.txt mentions a size cap other than the separate RAM gate.
  • No test covers it. Upstream TestLRUQueryCache.testMinSegmentSizePredicate uses segments of at most 55 documents.
The devil's-advocate check228 words

The strongest case for "intended" (a reviewer from another model family, OpenAI GPT-5.6, rated it "moderate"): LRUQueryCache is @lucene.experimental; caching is an optional optimisation, off by default on main; "will only be cached on leaves that ..." is a necessary condition, not a promise that every such leaf is cached, and other gates can reject entries too; and a maintainer could accept extra misses at this scale, "especially for unusually large cache entries". A related practical objection: a dense cached entry for a 2^30-document segment is about 128 MiB and can evict many smaller entries.

Why it fails: the javadoc's next sentence states the design goal ("This should guarantee that all leaves from the upper ... tier will be cached"), and the issue and PR it came from say the same; every stated condition is met here, and the predicate says no only because of the wrap; @lucene.experimental allows the API to change, not the arithmetic to be wrong; the scale is legal and exercised by Lucene's own tests; and entry size already has its own policy, the RAM gate, which admits the index only when its share of the budget has room for five worst-case entries, so the predicate is not the place that limits it. These arguments limit severity, not validity. The same reviewer noted that "the exact signed-overflow boundary has no plausible policy rationale".

Suggested fix

Recommended Widen the product to long: 2L * maxDoc

Widen the product to long: return 2L * maxDoc > averageTotalDocs; (one line, no API or format change). With it the repro passes with assertions on and off, a real 2^30-document segment is cached again, and the core test classes that use LRUQueryCache pass.

-      return maxDoc * 2 > averageTotalDocs;
+      return 2L * maxDoc > averageTotalDocs;
All options and trade-offs3 options · 373 words

Two equivalent one-line fixes; both were run through gradle in a scratch worktree of 645d4fe14a20.

Widen the product to long: 2L * maxDoc (validated by the checks) (recommended)

In MinSegmentSizePredicate.test (LRUQueryCache.java:294).

-      return maxDoc * 2 > averageTotalDocs;
+      return 2L * maxDoc > averageTotalDocs;

Trade-offs

No correctness, API or format trade-off found: the same answer as today for every segment below 2^30 documents and "more than half of the average" for every legal size; one long multiply per segment per caching decision. By design it starts caching on such segments: a dense entry for 2^30 documents is about 128 MiB and can evict other entries, within the existing RAM, entry-count and LRU limits.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: without the fix the repro fails with assertions on and off.
  • With the fix: it passes with assertions on and off (-Ptests.asserts=false).
  • Closest suites: every core test class that uses LRUQueryCache (TestLRUQueryCache, including testMinSegmentSizePredicate, TestUsageTrackingFilterCachingPolicy, TestBooleanScorer, TestDocValuesOrdinalSetIterator): 4 classes, 88 tests, 0 failures, 3 skipped (@AwaitsFix).
  • ./gradlew :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the repro test in place. The full test suite was not run.
  • Earlier, shadowed into the stock main jars: the minimal test passes with assertions on and off, and the real 2^30-document segment is cached (cacheCount=1 hitCount=1).

Halve the average instead: maxDoc > averageTotalDocs / 2 (validated by the checks)

Stays in int and cannot overflow.

-      return maxDoc * 2 > averageTotalDocs;
+      return maxDoc > averageTotalDocs / 2;

Trade-offs

For non-negative integers 2 * m > a holds exactly when m > floor(a / 2), so it gives the same answers as the long product for every input. Slightly less direct to read as "more than half of the average".

What the checks validated

Gradle in the same scratch worktree: the repro and TestLRUQueryCache pass with assertions on (41 tests, 0 failures, 3 skipped as @AwaitsFix). Assertions off, the other suites and the static checks were not run for this option.

Math.multiplyExact (not recommended)

Detects the overflow instead of avoiding it.

Trade-offs

Turns the silent miss into an ArithmeticException on every search of such a segment. Not tried.

Downstream note

Only the two-argument constructor's default rule is affected: products that pass their own leavesToCache predicate to LRUQueryCache keep their own behaviour.

How it was found

Non-formal: during a formal-verification study of Lucene, a reviewer reading LRUQueryCache while checking other findings noticed the int product. Follow-up tests drove the predicate with reported sizes around 2^30 and end to end through the cache; later a real 2^30-document segment, built through the public API, confirmed it.

The method in full111 words

Method: non-formal (code reading, then executed tests). In the 2026-10-22 study, a verifier saw the int multiplication as a side observation. The study's follow-up wrote three tests: the package-private predicate driven directly with a FilterLeafReader that reports a large maxDoc (single leaf, and a 2^30-document leaf next to a 20,000-document one), and an end-to-end search through LRUQueryCache(100, 64 GB) with an always-cache policy. All three fail with assertions on and off. The hunt then cut it to one test, re-ran it on current main and on release jars, and a tie-breaker built a real single segment of exactly 2^30 documents with IndexWriter.addIndexes to rule out an artefact of the reader wrapper.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, open PRs, the JIRA archive, the Lucene lists, downstream trackers or the web (2026-11-01); not fixed upstream.

Searches4 searches

Closest items, none a report of this bug: #12147 and #12140 (the origin; the review and the thread discuss averages, medians and tier layouts of up to 1M documents per segment, never overflow); the backports #12163 and #12164; #8290 / LUCENE-7235 (introduced the predicate in 2016 with a float ratio); #14187 (default cache off on main); open PRs #16952, #16757 and #16306 touch query-cache files but not the predicate. Limits: GitHub search does not index code or diffs; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues) and commits2026-11-01the hunt's prior-report tool (16 issue/PR and 3 commit queries) plus 14 own phrasings, phrase searches for "maxDoc * 2" LRUQueryCache and "2L * maxDoc", an in:comments search; the reproducer's 8 and the tie-breaker's 4 phrasingsno report; MinSegmentSizePredicate and averageTotalDocs give 0 issues, PRs and commits
Open PRs2026-11-01changed files of all 410 open PRs; diffs of the three that touch query-cache filesnone changes the predicate
JIRA archive, mailing lists, downstream trackers, web2026-11-014 JIRA queries; 20 queries over dev@, java-user@, issues@ and commits@; 2 downstream-repository queries and own searches of the Elasticsearch, OpenSearch and Solr trackers; 6 web queriesno report
Upstream main2026-11-01file contents of LRUQueryCache.java at 2f424fd428da (2026-10-30)still return maxDoc * 2 > averageTotalDocs;

History

Introduced in 9.6.0 by #12147 (8e15c665be9, 2023-02-16), which brought the average-based rule and the int product together. 9.6.0 to 10.6.0 and main fail; 9.5.0 is not affected.

Versions tested6 rows

Introduced by #12147 "Ensure caching all leaves from the upper tier" (closes #12140): main commit 8e15c665be9 (2023-02-16), branch_9x backport 57e1a4a40ef (2023-02-20), the only change to LRUQueryCache.java between the 9.5.0 and 9.6.0 tags. A pickaxe search for the expression finds only these two commits; the line is unchanged since. 9.5.0 compared a float ratio, which cannot overflow.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); a real 2^30-document segment is never cached; passes with the fix
10.6.0 affectedminimal repro fails with assertions on and off
10.5.2 affectedminimal repro fails with assertions on and off; a real 2^30-document segment is never cached
10.0.0, 9.12.3 affectedminimal repro fails with assertions off
9.6.0 first affectedminimal repro fails with assertions on and off
9.5.0 float ratiothe same check with the 9.5.0 constructor passes; a 2^30-document leaf is cached

Independent checks

Verified real with high confidence: the study's tests, a current-main reproducer and a tie-breaker that built a real 2^30-document segment all reproduce it. A reviewer from another model family, unable to run code, agreed with the root cause.

Checks and verdicts175 words

Nobody found it intended; all agree it is performance only, with correct results.

Each independent check and its verdict
CheckVerdictWhat it found
study follow-up reproducedThree tests (single leaf, two leaves, end to end) fail 3/3 with assertions on and off; re-run unchanged on main b83101cdd2, a9779ef3c193 and 645d4fe14a20: still 3/3.
minimisation verifiedThe one-test repro fails on main with assertions on and off and with two seeds; passes with 2L * maxDoc.
current-main reproducer realFails on main and 10.5.2 (assertions on and off), 10.6.0, 9.12.3 and 9.6.0; the fix passes.
cross-model review (OpenAI GPT-5.6, refute lens, read-only) not real, low confidence (procedural)It could not write or run code, so it could not reproduce; it agreed with the root cause, found the inputs legal, and rated the intended-behaviour argument moderate.
tie-breaker real, highRe-ran the minimal test and the fix; a real 2^30-document segment is never cached on main and 10.5.2, a 2^30 - 1 one is, and the fix caches the 2^30 one.

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 notes649 words

Finding record

LFV-081, snapshot of 2026-11-06

Registry title
LRUQueryCache MinSegmentSizePredicate overflows maxDoc * 2, so segments of 2^30+ docs are never cached
Area
search / query cache
Priority
Low, rank 1034: LRUQueryCache's MinSegmentSizePredicate overflows so segments of 2^30 or more docs are never cached, performance only at extreme scale with a large non-default cache.
Impact class
minor
Impact note
Query-cache users with the default MinSegmentSizePredicate and segments of 2^30 or more documents; performance only; released since 9.6.0 (#12147).
Summary
You use the query cache with its MinSegmentSizePredicate on a segment of 2^30 or more documents. You expect large segments to be cacheable. Instead maxDoc * 2 overflows int, the predicate rejects them from 2^30 docs on, and such a segment is never cached.
Introduced
8e15c665be9 (#12147) (2023-02-16)
First affected version
9.6.0 [exec 2026-11-01: the minimal test fails on the 9.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da, and the same check with the 9.5.0 constructor passes on 9.5.0; 9.6.0 has #12147 through the branch_9x backport 57e1a4a40ef]
Affected versions
every release from 9.6.0 through 10.6.0 and main 645d4fe14a20 (maxDoc * 2 > averageTotalDocs is in LRUQueryCache.java at every tag from 9.6.0 on [read]); the minimal test fails on main (-ea and -da) and on the 9.6.0 (-ea and -da), 9.12.3 (-da), 10.0.0 (-da), 10.5.2 (-ea and -da) and 10.6.0 (-ea and -da) release jars (SHA-1 checked against Maven Central) [exec 2026-11-01]; 9.5.0 and earlier are not affected (a float ratio; the same check with the 9.5.0 constructor passes) [exec]; not fixed upstream (main 2f424fd428da, 2026-10-30) [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real and a prior-report search; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-22, the study's read-only gh searches; 2026-11-01, a prior-report search over GitHub issues, PRs and commits, the changed files of all 410 open PRs, the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, downstream trackers and the web: verdict new); not fixed upstream (main 2f424fd428da still has the line)
Lucene commit tested
main 645d4fe14a20 (the minimal repro with assertions on and off, as a standalone JUnit run and through gradle; a real single segment of 2^30 documents through the public API); the SHA-1-checked release jars 9.5.0 (not affected), 9.6.0, 9.12.3, 10.0.0, 10.5.2 (also the real 2^30-document segment) and 10.6.0; the fix in a scratch worktree of 645d4fe14a20. Earlier, the study's three tests on main b83101cdd2 and a9779ef3c193.

Lucene and upstream sources

Finding record
  • a Lucene clone tag releases/lucene/10.5.2: lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:196
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:251-253, :277-296, :792-808, :823, :834, :838, :910 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:82, :145-151, :251-253, :792-801 (at 645d4fe14a20)
  • lucene/MIGRATE.md:227-236; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; lucene/CHANGES.txt:343 (GITHUB#14187)
  • a Lucene clone tag releases/lucene/10.5.2: IndexSearcher.java:96-99, LRUQueryCache.java:787
  • lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:52-59, :77; IndexWriter.java:208, :3264-3267
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-48, :85
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:117, :243-250 (at 645d4fe14a20)
  • a Lucene clone tag releases/lucene/9.5.0: LRUQueryCache.java:170-171
  • lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:1717-1761
  • https://github.com/apache/lucene/issues/12140 (labels; issuecomment-1426119587 by jpountz, 2023-02-10; read-only GET 2026-11-01)
  • https://github.com/apache/lucene/pull/12147 (label type:bug; merged 2023-02-16); git show 8e15c665be9 (commit message)
History
  • a Lucene clone: git show 8e15c665be9, 57e1a4a40ef; git log releases/lucene/9.5.0..releases/lucene/9.6.0 -- LRUQueryCache.java

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20. The numbers are the predicate's arithmetic and a separate probe (a FilterLeafReader reporting maxDoc 2^30 over a 1-document segment, LRUQueryCache(100, 16 GiB)), which match the real-segment runs; the Trigger and Affected lines of the summary card follow this doc.

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.