All findings · Lucene finding · LFV-908 · cachingcollector-negative-limit-reported-cached-replays-nothing

A negative cache limit makes grouped search silently return groups with no hits

GroupingSearch.setCaching(-1, true), for example meant as "no limit", is accepted, caches nothing and still reports the cache as complete, so the grouped search returns its 3 groups with 0 hits and totalHitCount 0 instead of 20, with no error. Silent since 9.8.0; 5.0.0 to 9.7.0 threw at replay.

Summary

Priority
Low, rank 393It silently drops every replayed hit, but only when someone passes a clearly invalid negative limit (such as -1 meant as 'no limit') to the opt-in, experimental first-pass caching, so it closes the accepted-degenerate-setting group after LFV-442.
Impact
silent wrong results
Since
5.0.0 (2014 commit); silent since 9.8.0 (2023)introduced 2014-04-04 · over 12 years
Affected
5.0.0 to 10.5.2 (107 releases run), 10.6.0 (branch_10_6 at RC1, run) and main
Status
documented
Found by
code reading for an SMT model's limit >= 0 precondition, then a public-API JUnit test

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

With caching on, a grouped search keeps the first pass's hits in a CachingCollector and replays them into the second pass instead of running the query again. The cache has a size limit, and isCached() says whether it kept every hit. A negative limit keeps nothing, yet isCached() still says yes.

Diagram of the repro's example: 20 matching documents by 3 authors (7, 7 and 6). Without a cache, and with a cache limit of 0 (the cache is purged and the query re-run), the grouped search returns totalHitCount 20. With a limit of -1 nothing is cached, the cache still reports isCached() true, the replay delivers 0 documents, and the search returns the 3 groups with 0 hits and totalHitCount 0.
The repro's example: a limit of 0 purges the cache and the query is run again; a limit of -1 caches nothing but reports the cache complete, so the second pass replays nothing. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 20 documents that all match body:match, by 3 authors (7, 7 and 6 documents). Group them by author with GroupingSearch, no caching: totalHitCount 20, 3 groups with 7, 7 and 6 hits.
  2. Turn on first-pass caching with setCaching(-1, true), for example meaning "no limit". You expect an IllegalArgumentException, or the same 20 hits: the javadoc says a cache over its limit is purged and not used, so the query is run again.
  3. You get the same 3 groups with 0 hits each and totalHitCount 0, and no error, with assertions on or off. setCachingInMB(-1.0, true) does the same, and a CachingCollector created with limit -1 reports isCached() true but replays 0 of 20 documents.
  4. Why: -1 is the cache's own "already purged" marker. With it, no segment is cached, so the only code that would clear the "cached" flag never runs. The flag starts true, so the second pass replays an empty cache instead of re-running the query.
  5. A limit of 0 works: the cache overflows at the first hit and the query is re-run. From 5.0.0 to 9.7.0 a negative limit threw an exception at replay; 9.8.0 made it silent.

Who is affected

Only applications that turn on first-pass caching (GroupingSearch.setCaching or setCachingInMB, or CachingCollector / CachingCollectorManager directly: experimental, off by default) with a negative limit, such as -1 meant as "no limit". Then every hit is lost at any index size (in GroupingSearch: field or function grouping, once a group matches). Limits of 0 or more do not trigger it.

Mechanism, terms and numbers631 words

GroupingSearch can cache the hits of its first pass in a CachingCollector and replay them into the second pass instead of running the query again. The caller gives the cache a size limit, and isCached() tells whether the cache kept every hit; if not, the query is re-run. A negative limit, for example setCaching(-1, true) meant as "no limit" or a computed RAM budget that went negative, is accepted without a check, but -1 is also the collector's own internal "purged" marker: no segment gets a caching collector, nothing is cached, and the flag that isCached() returns, which starts true and is cleared only by a caching segment collector, stays true. The second pass replays an empty cache: on 20 matching documents the grouped search returns its 3 groups with 0 hits each and totalHitCount 0, with no error, with assertions on or off. The same goes for setCachingInMB(-1.0, true), CachingCollector.create(.., -1) and the new CachingCollectorManager; through GroupingSearch it takes field or function grouping and a query that finds at least one group. Limits of 0 or more do not trigger it. The defect dates from LUCENE-5527 (5.0.0); until 9.7.0 it threw at replay, and #12380 (9.8.0) made it silent. Rejecting negative limits with an IllegalArgumentException turns the silent loss into an immediate error: this doc's test, the finder's test, the caching tests of the core module and every upstream grouping test pass with it.

Terms

  • Two-pass grouping: GroupingSearch first finds the top groups (first pass), then collects the top documents inside those groups (second pass). Each pass normally runs the query.
  • First-pass cache: with setCaching(maxDocsToCache, cacheScores) or setCachingInMB(maxMB, cacheScores), the first pass goes through a CachingCollector (since #16247 one per slice, through CachingCollectorManager) that records the matching doc ids per segment. If the cache stayed within its limit, isCached() is true and the second pass is fed by replay(); otherwise the cache is purged and the query is run again.

The bug

  1. The cache flag starts true (CachingCollector.java:337). Only a caching segment collector clears it, when a hit would go over the limit (:178-182, :189-199). Its finish() then sets the collector's remaining limit to -1, the internal "purged" state, so later segments are not cached (:92-96, :214-221).
  2. getLeafCollector creates a caching segment collector, and records the segment, only when the limit is >= 0 (:79-90); for -1 it hands back the wrapped collector untouched.
  3. Nothing checks the sign of a caller's limit: create(.., int) and create(.., double) (:307-330; -1.0 MB becomes -262144 documents, or -131072 when scores are cached, :312), the CachingCollectorManager constructor (CachingCollectorManager.java:73-75, only "exactly one limit") and the GroupingSearch setters (GroupingSearch.java:250-272).
  4. So a caller's -1 starts in the purged state with the flag still true. No segment is cached and nothing can clear the flag: isCached() is true, and replay() (:106-118) loops over an empty segment list and returns normally. GroupingSearch replays whenever isCached() is true (GroupingSearch.java:208-209).

The numbers (executed on main)

After the first pass, a limit of 0 ends in cached=false maxDocsToCache=-1, a limit of -1 in cached=true maxDocsToCache=-1 with 0 recorded segments. The first pass itself is unaffected (it collected all 20 documents, so the 3 top groups are right); the second pass, fed by the empty replay, finds no documents in them:

settingtotalHitCounthits per group (author0, author1, author2)
no caching207, 7, 6
setCaching(0, true)207, 7, 6 (cache purged, query re-run)
setCaching(1000, true)207, 7, 6 (replayed)
setCaching(-1, true)00, 0, 0
setCachingInMB(-1.0, true)00, 0, 0

CachingCollector.create(collector, false, -1) directly: the first pass collects 20, isCached() is true, replay() delivers 0. totalGroupedHitCount is 0 as well.

Exposure and likelihood264 words
  • Who is exposed. Code that enables first-pass caching with a negative limit. Caching is off by default (both limits start null, GroupingSearch.java:58-59), and all three classes are @lucene.experimental. Through GroupingSearch only field or function grouping is affected: the cache is built only in groupByFieldOrFunction (:128-137, 167-176); doc-block grouping does not use the cache limits.
  • How a negative limit gets there. Most plausibly -1 meant as "no limit": IndexWriterConfig uses -1 (DISABLE_AUTO_FLUSH) as the "off" value of its RAM-MB and document-count flush limits. Or a computed budget (for example "free heap minus a reserve") that went negative. 0, NaN and tiny negative MB values (above about -3.8e-6) truncate to 0 and work. A limit of 0 or more cannot trigger it: the effective document limit then stays >= 0 (CachingCollector.java:312); tested were 0, 1000, Integer.MAX_VALUE, 1.0 MB and 4.0 MB.
  • Effect. Every second-pass hit is lost: groups without documents, totalHitCount and totalGroupedHitCount 0. Nothing is added; the top groups themselves are right. No exception since 9.8.0, with assertions on or off. It needs a first pass that finds at least one group: a query that matches nothing returns its (correct) empty result before the replay decision (GroupingSearch.java:193-195).
  • Scale. None needed: 20 documents in one segment; an independent repro used 21 documents in 3 segments.
  • How likely. Low. A negative limit is a mistake, the documented searches found no report of either form, and Solr's grouping, the one downstream caller the checks read, only creates the cache when its computed limit is > 0.

Minimal repro

The test shows that GroupingSearch.setCaching(-1, true) is accepted, caches nothing, still reports the cache as usable, and so replays no document into the second pass.

TestGroupingNegativeCacheLimit.java (JUnit test)49 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.grouping;

import java.io.IOException;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestGroupingNegativeCacheLimit extends LuceneTestCase {

  public void testNegativeMaxDocsToCache() throws IOException {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new SortedDocValuesField("author", new BytesRef("a")));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        GroupingSearch groupingSearch = new GroupingSearch("author").setCaching(-1, true);
        TopGroups<BytesRef> groups =
            groupingSearch.search(new IndexSearcher(reader), MatchAllDocsQuery.INSTANCE, 0, 10);
        // expected 1 hit (the documented fallback: cache purged, query re-run); actual 0
        assertEquals(1, groups.totalHitCount);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-908/, 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:$LUCENE/lucene/grouping/build/libs/lucene-grouping-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.grouping.TestGroupingNegativeCacheLimit
Expected vs actual
Expected the grouped search over one matching document to report totalHitCount 1 (the documented fallback: cache purged, query re-run); actual: 0 (expected:<1> but was:<0>), silently, with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.search.grouping.TestGroupingNegativeCacheLimit.testNegativeMaxDocsToCache(TestGroupingNegativeCacheLimit.java:45)

Why it looks unintended

  • CachingCollector javadoc: if isCached() is true, "you can use replay", else re-run the search. Here it is true, and the replay is empty.
  • GroupingSearch.setCaching: an over-limit cache "is purged and not used in the second pass search". A limit of 0 does that; -1 does not.
  • No javadoc or test gives negative limits a meaning; -1 is an internal marker.
The full argument300 words
  • Class contract (CachingCollector.java:25-30): "Once the collection is done, call isCached. If this returns true, you can use replay(Collector) against a new collector. If it returns false, this means too much RAM was required and you must instead re-run the original search." isCached() (:340): "Return true is this collector is able to replay collection." replay() (:349-350): "@throws IllegalStateException if this collector is not cached (i.e., if the RAM limits were too low for the number of documents + scores to cache)."
  • Limit contract. create(..) (:304-305, :323-324): "If the collector exceeds the threshold, no documents and scores are cached." GroupingSearch.setCaching / setCachingInMB (GroupingSearch.java:241-244, 258-261): "If the cache grows beyond the specified limit, then the cache is purged and not used in the second pass search." CachingCollectorManager's example re-runs the query when isCached() is false.
  • Tests. No test uses a negative limit. TestCachingCollectorManager expects a limit of 0 to give isCached() false and replay to throw IllegalStateException, and already expects IllegalArgumentException for a wrong combination of limits.
  • History. The >= 0 gate, the -1 marker and the cached = true start came with LUCENE-5527 (2014), whose discussion never mentions the limits. #12380 (2023) made the failure silent as a side effect of an unrelated refactoring; no review comment mentions CachingCollector. #16247 (2026) added the manager's "exactly one limit" check without the sign.
  • Maintainers prefer a loud failure here. In LUCENE-4372 (#5438, open since 2012) the worry was a too-small limit silently dropping hits; it was settled because replay() throws: "I don't have any concerns if you are gonna get an exception." A negative limit gets around exactly that exception. Nearby APIs validate similar values (TopScoreDocCollectorManager: "numHits must be > 0"; PhraseQuery: "Slop must be >= 0").
The devil's-advocate check165 words

The strongest case for "works as designed" (the cross-model reviews rated it moderate): a maximum document count or RAM size has an implicit non-negative domain, so -1 is invalid input; the classes are @lucene.experimental; disableCaching() exists for "no cache"; Solr guards its own call with > 0; negative limits never worked (they threw from 5.0.0 to 9.7.0) and no report of it was found.

Why it fails: an implicit domain the API does not enforce is what argument validation is for. Nothing documents a range or a meaning for negative values, the setters accept them silently, and the result breaks the class's own contract (isCached() true means the replay is usable) with silently wrong results instead of the exception the maintainers relied on in #5438. Verdict: not intended; the remaining doubt is about impact and wording. What the case does get right: rejecting negative limits changes behaviour for the few such calls that return correct results today (see the fix trade-offs).

Suggested fix

Recommended Reject negative (and NaN) limits, also eagerly in the GroupingSearch setters

Reject a negative (or NaN) limit with an IllegalArgumentException in CachingCollector.create, the CachingCollectorManager constructor and the two GroupingSearch setters (+23 lines, plus regression checks in 3 test classes). This doc's test passes, and so do the core caching tests and every upstream grouping test, with assertions on and off.

// CachingCollector.create(Collector, boolean, int); likewise CachingCollectorManager, GroupingSearch.setCaching
if (maxDocsToCache < 0) {
  throw new IllegalArgumentException("maxDocsToCache must be >= 0, got " + maxDocsToCache);
}
// CachingCollector.create(Collector, boolean, double); likewise CachingCollectorManager; setCachingInMB checks maxCacheRAMMB
if (maxRAMMB >= 0 == false) { // also rejects NaN
  throw new IllegalArgumentException("maxRAMMB must be >= 0, got " + maxRAMMB);
}
All options and trade-offs4 options ยท 784 words

Three shapes were run on main. The recommended one reports the mistake where it is made; the core-only shape is the finder's patch; the alternative keeps today's API and makes a negative limit behave like 0.

Reject negative (and NaN) limits, also eagerly in the GroupingSearch setters (validated by the checks) (recommended)

The change is +23 lines in 3 files: the two CachingCollector.create factories, the CachingCollectorManager constructor and GroupingSearch.setCaching / setCachingInMB throw IllegalArgumentException("... must be >= 0, got ..."), with @throws lines in their javadoc. fix-reject-negative-tests.diff (+36 lines) adds the checks to TestCachingCollector, TestCachingCollectorManager.testConstructor and TestGroupingSearch.

// CachingCollector.create(Collector, boolean, int); likewise CachingCollectorManager, GroupingSearch.setCaching
if (maxDocsToCache < 0) {
  throw new IllegalArgumentException("maxDocsToCache must be >= 0, got " + maxDocsToCache);
}
// CachingCollector.create(Collector, boolean, double); likewise CachingCollectorManager; setCachingInMB checks maxCacheRAMMB
if (maxRAMMB >= 0 == false) { // also rejects NaN
  throw new IllegalArgumentException("maxRAMMB must be >= 0, got " + maxRAMMB);
}

Trade-offs

  • A call with a negative limit now throws: a behaviour change, not only a bug fix. Most such calls give wrong results today (or threw at replay before 9.8.0), but some give correct results and would now fail too: a grouped search whose first pass finds no group returns before the replay (GroupingSearch.java:193-195), and a direct CachingCollector user that never calls isCached() / replay() gets the wrapped collection only.
  • NaN, which truncates to 0 and works by accident today, now throws; check maxRAMMB < 0 instead to keep accepting it.
  • With the setter checks the error comes from setCaching itself; without them (next option) it comes from GroupingSearch.search. Code that sets -1 and expects only the search to fail sees it earlier: the independent reproducer's 2 GroupingSearch tests call the setter outside their try and so fail with the rejection's IllegalArgumentException, an outcome their oracle accepts only inside the try.
  • Argument checks only, outside the collection loop: no performance effect; no benchmark was run. A PR would add a CHANGES entry.

What the checks validated

  • Gradle on main, assertions on and off: TestCachingCollector* in core (2 classes, 9 tests) and the whole grouping suite (14 upstream classes, 57 tests) pass, and so do this doc's test (4 of 4), the finder's (5 of 5) and the independent reproducer's manager test (3 of 3). The only 2 failures are that reproducer's 2 GroupingSearch tests, which get the setter's new exception outside their try (see the trade-offs).
  • On stock main the same selection fails only in the repro classes (14 of 29 grouping tests), with assertions on and off.
  • :lucene:core:check -x test and :lucene:grouping:check -x test pass with the patch, its tests and this doc's test (google-java-format, ecj lint, forbidden APIs).

Reject only in CachingCollector.create and the CachingCollectorManager constructor (the finder's patch) (validated by the checks)

The same checks without the GroupingSearch setter checks and without the javadoc lines (+10 lines in 2 files). GroupingSearch.setCaching(-1, true) is still accepted, and the next search throws from the manager's constructor.

Trade-offs

Smallest change, all in core; the grouping user sees the error one call later, at search time, which is still before any result is returned.

What the checks validated

Gradle on main, assertions on: core 8 of 8 and the whole grouping suite 75 of 75, the independent reproducer's tests included; earlier the finder ran the 4 upstream caching and grouping suites with it.

Treat a negative limit as "cache nothing": clear the flag in create() (validated by the checks)

The change is +9/-3 lines in CachingCollector.create(Collector, boolean, int): when the limit is negative, set cached = false on the new collector before returning it. A negative limit then behaves like 0: isCached() is false, GroupingSearch re-runs the query, and replay() throws IllegalStateException as documented.

CachingCollector collector =
    cacheScores
        ? new ScoreCachingCollector(other, maxDocsToCache)
        : new NoScoreCachingCollector(other, maxDocsToCache);
if (maxDocsToCache < 0) {
  // a negative limit leaves room for no document: cache nothing and report the cache as purged
  collector.cached = false;
}
return collector;

Trade-offs

No new exception, so it is the gentlest backport. But the mistake stays hidden: a caller who meant -1 as "no limit" silently gets no caching at all and runs every grouped query twice.

What the checks validated

Gradle on main, assertions on: core 8 of 8 and the whole grouping suite 75 of 75; every repro passes because the right result comes back.

Give -1 the meaning "no limit"

Read a negative limit as unbounded, like IndexWriterConfig's -1.

Trade-offs

A new feature rather than a fix, and it removes the RAM guard the limit exists for (4 or 8 bytes per hit, which the class javadoc warns can be "a very substantial amount of RAM"). Not run.

Downstream note

A product that lets its users set a grouping cache limit should reject negative values itself until Lucene does; the effect is lost results for that request, never extra documents.

How it was found

While modelling GroupingSearch's cache limit with an SMT solver, an AI tool wrote the limit's domain as >= 0. Reading the code for that precondition showed that -1 is the cache's own "purged" marker; a probe and then a public-API JUnit test on the stock main jars confirmed the empty replay.

The method in full132 words

Method: semi-formal (a model's precondition, then code reading and executed tests).

  • Hunt (2026-10-28). A hunt on another question (does GroupingSearch apply its cache limit per slice since #16247?) wrote an SMT model of the cache bound in which the limit's domain is D >= 0. Checking that precondition against the code showed that D = -1 is CachingCollector's own "invalidated" state and that no API rejects it.
  • The finder then ran a probe and a public-API JUnit test (TestCachingNegativeLimit, 4 of 5 fail on main, assertions on and off; the limit-0 control passes), a version-agnostic main() over 12 releases from 8.0.0 to 10.5.2 (silent from 9.8.0, an exception before), and the reject-negative fix.
  • Independent checks followed (see below); the history step extended the sweep to 135 releases from 3.2.0.

Prior reports, history and checks

Prior reports

No prior report found: three search runs (51 to 67 queries each) over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream trackers and the web, plus the changed files of open PRs, searched 2026-10-28. Nearest: #5438 (LUCENE-4372), about too-small positive limits.

Searches4 searches

The documented searches found no report of a negative cache limit that is accepted and replays nothing, or of the older exception at replay (limits: GitHub search does not index inline review comments, so the #16247 and #12380 review threads were read directly; older open PRs were checked by title; web search is best effort). #5438 (LUCENE-4372, open since 2012) concerns too-small positive limits, which purge correctly; its discussion is the best citation for "throw rather than silently drop hits". #12380 made the failure silent without discussing it; #16247 added the manager's limit check without the sign. Open PRs #15210, #16847 and #14996 touch CachingCollector.java or GroupingSearch.java, but not the limit handling.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-28three scripted prior-report searches (67, 59 and 51 queries: class and method names, maxDocsToCache, setCaching, isCached, symptom and history terms) and manual searches, refreshed at the registration checkno report; related context only: #5438, #12380, #16247, #4175, #10401, #5436, #4851
Open and recent PRs2026-10-28titles of all 411 open PRs; changed files of the 192 open PRs updated since 2026-06-01; PRs created or updated since 2026-10-26#15210, #16847, #14996 touch the files, not the limit handling
Mailing lists and JIRA2026-10-28dev@, java-user@, issues@, commits@ (lists.apache.org); LUCENE and SOLR JIRA archiveusage threads only
Downstream and web2026-10-28Solr, Elasticsearch, OpenSearch and Lucene.NET on GitHub; all of GitHub for the inner class names and the replay stack trace; web searchesnothing about negative limits

History

Root cause since LUCENE-5527 (85642f59ddf, 2014-04-04, first release 5.0.0); from 5.0.0 to 9.7.0 replay() threw, and #12380 (8811f31b9ca, 2023-06-30, 9.8.0) made it silent. Affects 5.0.0 to 10.5.2, 10.6.0 (branch_10_6 at RC1) and main; 3.2.0 to 4.10.4 are correct.

Versions tested6 rows

Root defect: LUCENE-5527 "Make the Collector API work per-segment" (GitHub #6590; trunk 85642f59ddf, 2014-04-04; branch_5x through the LUCENE-5944 backport 55594bad9b0; first release 5.0.0). It brought the cached = true start, the maxDocsToCache >= 0 gate and the -1 "purged" marker, all still blamed to it on main. Before (3.2 to 4.10.4) the cache checked the limit when its first 128-document chunk was full; a negative limit then purged it and isCached() became false.

Symptom change: #12380 "Add a post-collection hook to LeafCollector." (jpountz, merged 2023-06-30; main 8811f31b9ca, branch_9x de07bdd88dd; first release 9.8.0) moved contexts.add(context) inside the >= 0 check. Before, a negative limit left one segment entry and no cached docs, so replay() hit assert docs.size() == contexts.size() (assertions on) or docs.get(0) on an empty list (off). Two single-file swaps confirm it: 9.8.0 with the old file throws again, and 9.7.0 with only that line moved is silent.

CachingCollectorManager (#16247; first released in 10.6.0, 2026-10-29) has the bug from its first commit. Not a 10.6.0 regression; not fixed upstream (no commit on the three files after #16247, checked 2026-10-28). All 162 history runs used JDK 25 on Linux aarch64, not each release's original JDK.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, branch_10_6 4ae946e5a9b affectedsilent: totalHitCount 0, 0 of 20 replayed, also through CachingCollectorManager
9.8.0 to 10.5.2 (23 releases) affectedsilent; this doc's test fails 3 of 4 on 10.5.2 and 10.0.0
5.0.0 to 9.7.0 (84 releases) fails loudlyreplay() throws AssertionError (assertions on) or IndexOutOfBoundsException (off)
3.2.0 to 4.10.4 (28 releases run; 4.0.0 and 4.1.0 not run) not affectedcorrect: the old cache purges itself and reports isCached() false
9.8.0 jars + the pre-#12380 CachingCollector.java swapthrows again: #12380 is what made it silent
9.7.0 jars + only the contexts.add move swapsilent: totalHitCount 0

Independent checks

An independent reproducer and reviews by another model family (OpenAI GPT-5.6) reproduced it with their own tests; an intended-behaviour devil's advocate re-ran it and argued the other side. All called it real; the adjudication confirmed it with a new seed, and a credibility audit re-ran it.

Checks and verdicts278 words

Every check ran on main a9779ef3c193 except the two outside reviews, whose runs used main ea57360912ed, where every file involved is identical. Earlier sandboxed attempts of the refute review could not start a command and are not counted.

Each independent check and its verdict
CheckVerdictWhat it found
independent reproducer realOwn tests (21 documents in 3 segments, limits -1, -5 and -1.0 MB, controls 1000 and 0) fail 5 of 7 on main, branch_10_6, 10.5.2 and 9.12.3 with assertions on and off, in the crash form on 9.7.0; a CachingCollectorManager test fails 2 of 3; both pass with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real, mediumOwn JUnit with a plain SimpleCollector over 7 documents: isCached() true, 0 of 7 replayed, three seeds, assertions on and off; the limit-0 control passes; the fix passes. Counter-argument: an implicit non-negative domain (moderate).
cross-model review, intended lens (OpenAI GPT-5.6, executed) real, highOwn core and GroupingSearch tests: 0 of 7 replayed, totalHitCount 0 instead of 7; also 9.8.0 and 10.5.2 (silent) and 9.7.0 (crash); the fix with 18 existing component tests.
intended-behaviour devil's advocate real, not intended, LowRe-ran the finder's test on main and branch_10_6 and the release probe on 10.5.2; built the works-as-designed case and showed why it fails.
adjudication confirmed, high, LowRe-ran the independent reproduction's tests on main with a new seed (51A7E0C3): 5 of 7 fail with assertions on and off; the manager test 2 of 3.
Registration check and credibility audit keepRe-ran the finder's test on main (seeds DEADBEEF and A0D17C4C, assertions on and off): 4 of 5 fail, the control passes.

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

Finding record

LFV-908, snapshot of 2026-11-06

Registry title
A negative cache limit (GroupingSearch.setCaching(-1, ..) / setCachingInMB(-1.0, ..), CachingCollector.create(.., -1), CachingCollectorManager) is accepted and caches nothing, yet isCached() stays true, so replay() delivers no documents and grouped search silently returns groups with no documents and totalHitCount 0 (since 9.8.0; from 5.0.0 to 9.7.0 replay threw instead)
Area
search / CachingCollector first-pass caching (CachingCollector, CachingCollectorManager) x grouping GroupingSearch two-pass caching
Priority
Low, rank 393: It silently drops every replayed hit, but only when someone passes a clearly invalid negative limit (such as -1 meant as 'no limit') to the opt-in, experimental first-pass caching, so it closes the accepted-degenerate-setting group after LFV-442.
Impact class
silent wrong results
Impact note
Hits only applications that opt into first-pass caching (GroupingSearch.setCaching/setCachingInMB, CachingCollector.create or the new CachingCollectorManager, all @lucene.experimental) and pass a clearly negative limit, for example -1 meant as 'no limit' or a computed budget that went negative; every non-negative limit works (0, and NaN or tiny MB values that round to 0, are purged correctly), no scale is needed, and since 9.8.0 the loss of all replayed hits is silent with -ea and -da, while 5.0.0 to 9.7.0 failed loudly at replay.
Summary
You enable caching for a grouped search (or wrap a first-pass collector in CachingCollector or CachingCollectorManager) with a negative limit, for example setCaching(-1, true) meant as 'no limit', and expect either an IllegalArgumentException or the documented fallback for a cache over its limit: isCached() false and the query re-run. Instead the limit is accepted and nothing is cached (a negative count is the collector's own internal 'purged' marker), but isCached() stays true, so replay() delivers none of the collected documents and GroupingSearch returns its top groups with no documents and totalHitCount 0 instead of 20 in the repro, with no error. From 5.0.0 to 9.7.0 the same input failed at replay with an AssertionError (-ea) or IndexOutOfBoundsException (-da).
Introduced
85642f59ddf00c469b502f46fbc39944e076190e (trunk, LUCENE-5527 'Make the Collector API work per-segment', GitHub mirror #6590, committed by jpountz, svn r1584747; reached branch_5x through the LUCENE-5944 bulk backport 55594bad9b0) [read: the cached = true start, the maxDocsToCache >= 0 gate in getLeafCollector and the -1 'purged' sentinel in invalidate() all still blame to it on main; exec at the release boundary: 28 releases 3.2.0-4.10.4 pass, 5.0.0 fails; no commit-level swap, the rewrite changed the whole Collector API]. The symptom turned silent with 8811f31b9ca (main) / de07bdd88dd (branch_9x), apache/lucene#12380 'Add a post-collection hook to LeafCollector' by jpountz, which moved contexts.add(context) under the maxDocsToCache >= 0 check [exec: the pre-#12380 CachingCollector.java shadowed onto 9.8.0 brings back the replay exception; moving only that line in the 9.7.0 file makes 9.7.0 silent]. CachingCollectorManager (#16247 by gaobinlong, 3afffeef5a8 / d60e550b47f, first released in 10.6.0) inherits it: its constructor checks only that exactly one limit is set. (2014-04-04 (LUCENE-5527 trunk commit; branch_5x backport 2014-09-18); silent form 2023-06-30 (#12380, author date of both commits); CachingCollectorManager leg 2026-07-08 (#16247))
First affected version
5.0.0 [exec] (replay throws AssertionError with -ea, IndexOutOfBoundsException with -da); silent wrong results from 9.8.0 [exec]; 4.10.4 and earlier are not affected [exec: the pre-5.0 chunked cache purges itself, so isCached() becomes false and no hits are lost]
Affected versions
[exec] every Maven Central release from 5.0.0 to 9.7.0 (84 releases) fails at replay (isCached() true, then AssertionError with -ea or IndexOutOfBoundsException with -da); every release from 9.8.0 to 10.5.2 (23 releases) returns silently wrong results (isCached() true, 0 documents replayed, GroupingSearch totalHitCount 0); 10.6.0 (the branch_10_6 head 4ae946e5a9b, the RC1 revision; finder JUnit 4 of 5 fail, also through CachingCollectorManager) and main a9779ef3c193 (4 of 5 fail with -ea and -da, re-run at the registration check 2026-10-28) fail the same way; 3.2.0-4.10.4 (28 releases) pass. The release sweep ran with -da on every release and with -ea on a subset, all with JDK 25 on aarch64 Linux rather than the JDK each release shipped for. Not a 10.6.0 regression. Not fixed upstream: no commit touches CachingCollector.java, CachingCollectorManager.java or GroupingSearch.java after #16247 on main (head c5ab3e57b9ba) or branch_10_6 (head 4ae946e5a9b9) [read, 2026-10-28].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (the prior-report record, verdict new; searched 2026-10-28, read-only; finder's own; refreshed at the registration check 2026-10-28): scripted prior-report searches with 67, 59 and 51 queries over GitHub issues, PRs and commits, downstream trackers (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists, JIRA and the web (0 high hits), manual GitHub searches (apache/lucene, solr, lucenenet, elasticsearch, OpenSearch, all of GitHub), the changed files of every open apache/lucene PR updated since 2026-06-01, and full reads of #4175, #5438, #12380 and #16247. No issue, PR, review comment, commit, CHANGES entry, list post or web page reports a negative cache limit being accepted, isCached() staying true for an empty cache, a silently empty replay, or the older IndexOutOfBoundsException from replay. Related context, not reports: #5438 (LUCENE-4372, open since 2012) is about too-small positive RAM limits and was settled because replay() then throws ('I don't have any concerns if you are gonna get an exception'), the guarantee a negative limit now bypasses; #12380 (jpountz, 9.8.0) made the failure silent as a side effect, and no review comment mentions CachingCollector; #16247 added CachingCollectorManager and its exactly-one-limit check without discussing the sign. Open PRs #15210, #16847 and #14996 touch CachingCollector.java or GroupingSearch.java but not the limit handling. Refresh: 'CachingCollector negative', 'maxDocsToCache', 'setCaching negative', 'isCached replay', 'GroupingSearch caching' and PRs updated or created since 2026-10-26 return no new item; none of the 46 open PRs updated since 2026-10-26 changes CachingCollector* or GroupingSearch*.
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: this doc's test, the finder's and the independent reproducer's tests and the upstream suites TestCachingCollector, TestCachingCollectorManager, TestGroupingSearch and TestGrouping, on stock with assertions on and off; the recommended fix with every TestCachingCollector* class of :lucene:core and the whole :lucene:grouping suite, assertions on and off, plus both modules' static checks; the finder's core-only patch and the alternative fix on the same selection, assertions on; the repro runner: the finder's, the verifiers', the adjudication's, the registry re-runs, the credibility audit and this doc's test, several seeds, assertions on and off); the release jars 10.0.0 and 10.5.2 (this doc's test); 135 releases from 3.2.0 to 10.5.2 with a version-portable probe; branch_10_6 head 4ae946e5a9b. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:25-38 (at a9779ef3c193) (class javadoc: isCached() true means replay is usable, false means re-run the search; @lucene.experimental), :79-90 (getLeafCollector wraps a leaf and records its context only if maxDocsToCache >= 0), :92-96 (invalidate() writes -1 as the purged state), :106-118 (replay: IllegalStateException only if !isCached(), then loops over the recorded contexts), :178-182 (only a caching leaf's invalidate() sets cached = false), :295-330 (both create factories, no sign check; a negative MB becomes a negative count), :332-343 (cached starts true; isCached)
  • lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:61-80 (at a9779ef3c193) (constructor checks only that exactly one limit is set)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:167-176 (at a9779ef3c193) (caching manager built from the stored limit), :207-212 (replay when isCached(), else re-run), :240-272 (setCachingInMB / setCaching javadoc: 'If the cache grows beyond the specified limit, then the cache is purged and not used in the second pass search'; setters store any value)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:72 (at a9779ef3c193) and LiveIndexWriterConfig.java:163, 214 (DISABLE_AUTO_FLUSH = -1, the -1 'no limit' convention cited for likelihood)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:79-118, 158-230, 297-343 (getLeafCollector gate, invalidate sentinel, leaf invalidate, create, cached flag)
  • lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:68-111 (constructor check, newCollector, isCached)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:141-219, 250-272 (caching manager, replay vs re-run, setters)
Who is affected
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44, 58-59, 128-137, 167-176, 193-195, 221-237 (@lucene.experimental; limits start null; only field/function grouping builds the cache; early return without top groups; doc-block grouping)
  • lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:38; lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:48 (@lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:70-72; LiveIndexWriterConfig.java:163, 214 (DISABLE_AUTO_FLUSH = -1)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:25-30, 304-305, 323-324, 340, 349-350
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:240-272; lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:33-45
  • lucene/core/src/test/org/apache/lucene/search/TestCachingCollectorManager.java:27-98
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:64; PhraseQuery.java:174
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:32-33 (RAM note)

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

Repro files

About the diagrams

A 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 finder's core-only fix sketch (the second fix option below). The recommended fix also rejects the limit in the GroupingSearch setters, so with it setCaching(-1, true) itself throws "maxDocsToCache must be >= 0, got -1".

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.