All findings · Lucene finding · LFV-154 · cachingcollector-replay-leaks-collectionterminated

CachingCollector#replay lets CollectionTerminatedException escape instead of ending only the current segment, as IndexSearcher does

Replaying a CachingCollector (or CachingCollectorManager) into a collector that stops a segment early lets its CollectionTerminatedException escape, where IndexSearcher just moves on to the next segment. A TopFieldCollector sorted by the index sort does this once it has counted over 1,000 hits (one segment with that many is enough); every release since 4.3.0 is affected (13 releases run, the rest by source history).

Summary

Priority
Low, rank 709CachingCollector replay lets a CollectionTerminatedException from an early-terminating second pass escape (wrong top hits if the caller catches it), loud and only for applications that drive replay themselves.
Impact
crash
Since
4.3.0 (LUCENE-4862, 2013)introduced 2013-03-21 · over 13 years
Affected
every release from 4.3.0 to 10.6.0 and main (13 releases and main run, the rest by source history); CachingCollectorManager (new in 10.6.0) too
Status
documented
Found by
a bounded-exhaustive check of the collector managers, then a JUnit

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

CachingCollector records a search's hits so that replay(other) can feed them to a second collector instead of re-running the query. replay drives that collector segment by segment, like IndexSearcher, but without IndexSearcher's catch for the per-segment stop signal.

Background: how a collector stops a segment early

  1. For each segment, IndexSearcher asks the collector for a leaf collector, calls collect(doc) for every match, and then finish().
  2. A leaf collector that needs no more hits from a segment throws CollectionTerminatedException. IndexSearcher catches it and continues with the next segment; that is how LeafCollector#collect documents it.
  3. A TopFieldCollector whose sort is the index sort uses it: the segment's documents arrive in sort order, so once its queue is full and it has counted more hits than its threshold (1,000 when IndexSearcher builds it), no later document of that segment can compete.
Diagram of the bug: an index sorted by ts with two segments (ts 1000..2999, then ts 0..1999). IndexSearcher catches CollectionTerminatedException from the TopFieldCollector in segment 0 and continues to segment 1, returning ts 0..9. CachingCollector.replay lets the exception escape, so segment 1 is never replayed: the caller gets the exception, or, if it catches it, ts 1000..1009. With the fix replay catches it per segment and returns ts 0..9.
The replay loop beside IndexSearcher's per-segment loop on the two-segment index-sort example: where the stop signal is caught, where it escapes, and the top 10 each returns. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Sort the index by a long field ts and write two segments, all documents matching f:a: segment 0 holds ts 1000..2999, segment 1 holds ts 0..1999. Cache a first pass with CachingCollector.create(false, 64.0).
  2. Replay the cache into new TopFieldCollectorManager(indexSort, 10, null, 1000).newCollector(), the collector IndexSearcher.search(query, 10, indexSort) builds. You expect that search's top 10: ts 0..9, all from segment 1.
  3. In segment 0 the collector has its best 10 hits after 10 documents. At the 1,001st hit it throws CollectionTerminatedException, the documented signal for "skip the rest of this segment". IndexSearcher catches it and goes on to segment 1; replay has no catch.
  4. So replay throws the exception, with no stack trace. Catching it yourself does not help: segment 1 was never replayed, and the top 10 come back as ts 1000..1009. CachingCollectorManager.replay also skips the remaining slices and reduce().

Who is affected

Applications that call CachingCollector#replay or CachingCollectorManager#replay (10.6.0) themselves with a second pass that ends segments early: an index-sorted or index-order TopFieldCollector (by default once it has counted more than 1,000 hits, for example on one segment with that many), a MultiCollector whose children all stop, or a custom collector. Lucene's GroupingSearch is not affected.

Mechanism, terms and numbers534 words

CachingCollector records the hits of a first search so that replay(other) can feed them to a second collector instead of re-running the query; CachingCollectorManager (new in 10.6.0) does the same per slice and then reduces. A leaf collector may end a segment early by throwing CollectionTerminatedException, which LeafCollector#collect documents as the per-segment stop signal and IndexSearcher.searchLeaf catches, both from getLeafCollector and from scoring (IndexSearcher.java:835-855 on main 645d4fe14a20). The replay loops have no such catch (CachingCollector.java:115, :100-102, :151-154), so the exception escapes replay, without a stack trace, and the remaining segments are never replayed. A stock TopFieldCollector sorted by the index sort throws it once it has counted more hits than its threshold (1,000 by default; one segment with that many is enough): replaying a two-segment index-sorted cache into it throws, and catching the exception returns the top 10 ts 1000..1009 instead of 0..9. It has been there since the exception was added in 4.3.0 (LUCENE-4862, 2013) and affects every release to 10.6.0 and main (13 releases and main executed, the others by source history). Catching the exception per segment in replay, as IndexSearcher does, fixes it; the core caching tests, the grouping module and the whole core module pass with the fix.

The two drivers (main 645d4fe14a20)

  1. IndexSearcher.searchLeaf (IndexSearcher.java:831-863) wraps collector.getLeafCollector(ctx) in try and returns on the exception (:835-841, "there is no doc of interest in this reader context"), wraps scoring in try and falls through on it (:849-855, "collection was terminated prematurely"), then calls leafCollector.finish() (:862).
  2. CachingCollector#replay (CachingCollector.java:106-118) calls other.getLeafCollector(context) (:115) and collect(collector, i) (:116) for each cached segment. collect loops over the cached doc IDs and calls collector.collect(doc) (:100-102, then finish() at :103); with cached scores, ScoreCachingCollector#collect runs the same loop with the score set (:151-154) but never calls finish() (LFV-180). There is no try anywhere, so the exception from either call leaves replay and the loop over segments stops.
  3. CachingCollectorManager#replay (CachingCollectorManager.java:118-130) creates one second-pass collector per slice, calls cache.replay(...) (:126) and then reduce(...) (:129). An exception in the first slice skips the other slices and the reduce.

Who throws it

  • TopFieldCollector (TopFieldCollector.java:92-103): when the search sort is part of the index sort and a document is not competitive, if (totalHits > totalHitsThreshold) throw new CollectionTerminatedException(); (:98-100). IndexSearcher#search(Query, int, Sort) passes TOTAL_HITS_THRESHOLD = 1000 (IndexSearcher.java:100, :733). On upstream main after #16857 (merged 2026-10-29) the same throw sits in IndexSortedLeafFieldComparator.
  • MultiCollector throws it from getLeafCollector when no child wants the segment (MultiCollector.java:142-143) and from collect once every child has stopped (:218-226).
  • Any custom collector, as the exception's javadoc invites: "Throw this exception in LeafCollector#collect(int) to prematurely terminate collection of the current leaf" (CollectionTerminatedException.java:19-21).

What the caller sees

The exception is stackless: fillInStackTrace is overridden because it is "never re-thrown" (CollectionTerminatedException.java:35-39), so the trace shows Caused by: org.apache.lucene.search.CollectionTerminatedException with no frames. Catching it around replay leaves the later segments unreplayed: in the index-sort example the top 10 are ts 1000..1009 instead of 0..9.

Exposure and likelihood199 words
  • Opt-in. CachingCollector and CachingCollectorManager are @lucene.experimental expert APIs (CachingCollector.java:38, CachingCollectorManager.java:48). Within that usage the defaults suffice: the index-sort example needs only a segment with more than 1,000 hits.
  • Loud by default. The request fails with a stackless runtime exception. Results are silently wrong only if the caller catches it around CachingCollector#replay: the second-pass collector it holds misses every segment after the first stopped one. Around CachingCollectorManager#replay a catch leaves no result at all, because the later slices and reduce() are skipped (CachingCollectorManager.java:123-129).
  • Deterministic. Same result with assertions on and off, with a sequential IndexSearcher; nothing is written or corrupted.
  • Not affected. GroupingSearch, the only in-tree replay user (GroupingSearch.java:209): its second-pass collectors use Integer.MAX_VALUE thresholds (TopGroupsCollector.java:134, :142) and never stop early (read, not executed). Solr's grouping cache likewise, by code reading.
  • Workaround. A second pass with a totalHitsThreshold of Integer.MAX_VALUE never throws (totalHits > totalHitsThreshold, TopFieldCollector.java:98, never holds), as in GroupingSearch; replay then feeds it every cached hit instead of stopping early. Or re-run the search instead of replaying (read, not executed).
  • Likelihood: rare. Nobody reported it in the 13 years since 4.3.0.

Minimal repro

One JUnit method on the public API: a one-document index, a CachingCollector filled by a MatchAllDocsQuery search, then replay into a SimpleCollector whose collect throws CollectionTerminatedException. Deterministic; the test itself uses no randomness (the test framework still randomizes codec and similarity defaults, fixed by the seed); fails the same way with assertions on and off.

TestCachingCollectorReplayTerminatedLeaf.java (one test, public API)59 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 org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCachingCollectorReplayTerminatedLeaf extends LuceneTestCase {

  @SuppressWarnings("deprecation")
  public void testReplayIntoCollectorThatTerminatesLeaf() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(new Document());
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        CachingCollector cache = CachingCollector.create(false, 1.0);
        new IndexSearcher(reader).search(MatchAllDocsQuery.INSTANCE, cache);
        SimpleCollector terminating =
            new SimpleCollector() {
              @Override
              public void collect(int doc) {
                throw new CollectionTerminatedException();
              }

              @Override
              public ScoreMode scoreMode() {
                return ScoreMode.COMPLETE_NO_SCORES;
              }
            };
        try {
          cache.replay(terminating);
        } catch (CollectionTerminatedException e) {
          // Expected: replay ends the leaf, as IndexSearcher.search does; actual: the exception escapes.
          throw new AssertionError("CachingCollector.replay let " + e + " escape", e);
        }
      }
    }
  }
}
Command
cp $REPRO/TestCachingCollectorReplayTerminatedLeaf.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCachingCollectorReplayTerminatedLeaf -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
Expected
replay returns normally: the collector's segment ends, as with IndexSearcher.search.
Observed
replay throws CollectionTerminatedException (no stack frames), with assertions on and off, on main, 10.6.0 and 10.5.2.

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

More files, environment, full output207 words

The test sits in org.apache.lucene.search, as Lucene's own tests do, but uses only public API. It uses the deprecated IndexSearcher#search(Query, Collector) because CachingCollector is a Collector; the manager form exists only from 10.6.0 on. It compiles against 10.5.2, 10.6.0 and main; 9.12.3 has no MatchAllDocsQuery.INSTANCE, so on older releases write new MatchAllDocsQuery().

The collector throws on its first hit, so one document is enough. The comment line in the catch states the expectation: IndexSearcher.search with the same collector ends the segment and returns. The realistic trigger is the two-segment index-sort example in "The finding in 30 seconds"; it is described there, not added as a second test.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on the 10.6.0 and 10.5.2 release jars
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/TestCachingCollectorReplayTerminatedLeaf.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCachingCollectorReplayTerminatedLeaf -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

TestCachingCollectorReplayTerminatedLeaf > testReplayIntoCollectorThatTerminatesLeaf FAILED
    java.lang.AssertionError: CachingCollector.replay let org.apache.lucene.search.CollectionTerminatedException escape
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:F0069F7D7DA18069]:0)
        at org.apache.lucene.search.TestCachingCollectorReplayTerminatedLeaf.testReplayIntoCollectorThatTerminatesLeaf(TestCachingCollectorReplayTerminatedLeaf.java:54)

        Caused by:
        org.apache.lucene.search.CollectionTerminatedException
> Task :lucene:core:test FAILED

# the same with -Ptests.asserts=false; the cause has no frames (the exception is stackless)

Why it looks unintended

  • LeafCollector#collect documents the exception as the way to end just the current segment (LeafCollector.java:76-79), and replay drives the segments itself.
  • The exception is stackless because it is "never re-thrown" (CollectionTerminatedException.java:37); the replay javadocs never mention it.
  • The core drivers catch it (IndexSearcher, MultiCollector, SuggestIndexSearcher); where upstream resolved the same omission elsewhere, it fixed the code (LUCENE-6772, #12609).
The full argument417 words
  • Contract. LeafCollector#collect: "The collection of the current segment can be terminated by throwing a CollectionTerminatedException. In this case, the last docs of the current LeafReaderContext will be skipped and IndexSearcher will swallow the exception and continue collection with the next leaf" (LeafCollector.java:76-79). LeafCollector#finish runs once the leaf "has finished collecting successfully, including when a CollectionTerminatedException is thrown" (:150-151), which reads as an obligation of whoever drives the leaf; the no-score replay calls finish() itself (CachingCollector.java:103; the scored replay does not, LFV-180). These javadocs name IndexSearcher, so applying them to replay is an inference from the collector protocol, not an explicit guarantee.
  • Replay replaces a search. CachingCollector: if the cache overflowed, "you must instead re-run the original search" (CachingCollector.java:25-31); CachingCollectorManager: "replayed to a second-pass CollectorManager without re-running the query" (CachingCollectorManager.java:24-27). The replay javadocs list what differs (no scorer without cached scores) and what they throw (IllegalStateException): CachingCollector.java:345-354, CachingCollectorManager.java:113-117. CachingCollector#replay also keeps a stale @throws IllegalArgumentException about out-of-order collection (:351-352; out-of-order scoring was removed in 5.0.0, LUCENE-6179, and replay never throws it). This exception is not among them.
  • The core drivers handle it. IndexSearcher (IndexSearcher.java:835-855), MultiCollector (MultiCollector.java:128-134, :218-226) and, since #12609, SuggestIndexSearcher (SuggestIndexSearcher.java:74-77). Not every in-tree driver does: SortRescorer and grouping's BlockGroupingCollector run only collectors they build with Integer.MAX_VALUE thresholds and have no catch (the reason jpountz gave on #12380, next point); the sandbox PostCollectionFaceting, which like replay feeds already-collected documents to collectors the caller supplies, has no catch either; and DrillSidewaysScorer.java:149-151 has an open TODO about the exception. (Read only; none of these was executed or is claimed as a bug.)
  • Precedent. LUCENE-6326 / LUCENE-6772 (#7387, #7830) fixed MultiCollector in 5.4. Before #12609, jpountz had explained on #12380 (2023-09-28) that SuggestIndexSearcher skipped the catch around getLeafCollector only because, "unlike IndexSearcher which may run any Collector", it runs a collector "which never throws a CollectionTerminatedException", and offered to approve the change; replay(Collector other) accepts any collector. LUCENE-6337 (#7398) reported the same omission in ToParentBlockJoinIndexSearcher; it was never fixed and is still open, and the class was removed in 2017 (LUCENE-6959).
  • No recorded decision. LUCENE-4862 created the exception and taught only IndexSearcher (its commit touches CHANGES.txt, CollectionTerminatedException.java, Collector.java, IndexSearcher.java and a test); its comments never mention CachingCollector. No commit ever added or removed a handler in replay, and no upstream test combines replay with the exception.
The devil's-advocate check203 words

Strongest case (the intended-behaviour check, the cross-model review and the review, each rating it at most moderate): the exception's javadoc says IndexSearcher swallows it and that you should not catch it when calling IndexSearcher's methods (CollectionTerminatedException.java:23-25), so the contract could bind IndexSearcher only and let other drivers' callers catch it; the APIs are @lucene.experimental; and replay is a lean pump that also skips setWeight and the competitive iterator.

Why it fails: catching it outside replay is not a correct use, because replay has already abandoned the remaining segments ([1000..1009] instead of [0..9]). The steps replay skips are optional by design (competitiveIterator "Optionally returns", setWeight a default no-op), while the exception is the one stop signal LeafCollector#collect documents. Experimental status allows API changes, not a driver that breaks the collector protocol, and nothing documents a "no early-terminating collectors" precondition. A documentation-only resolution is possible, but wherever upstream resolved this omission in another driver, it fixed the code (LUCENE-6772, #12609) and never documented a limitation instead; LUCENE-6337 stayed open until its searcher was removed in 2017. Conceded: the javadocs do not promise IndexSearcher's semantics for replay in so many words, and an Integer.MAX_VALUE threshold avoids the stock trigger.

Suggested fix

Recommended Catch the exception per segment in replay, as IndexSearcher.searchLeaf does

Catch CollectionTerminatedException per segment in CachingCollector, as IndexSearcher.searchLeaf does: around getLeafCollector in replay (skip the segment) and around both replay collect loops (end the segment). One file, +21/-6. The test and the index-sort scenario pass, and so do the core caching tests, the grouping module and the whole core module.

All options and trade-offs3 options ยท 599 words

Two options were built and run in a scratch worktree of 645d4fe14a20; a documentation-only change is not recommended. LFV-180 (the missing finish() in the scored replay and in the wrapped first pass) touches the same method, so option B combines both.

Catch the exception per segment in replay, as IndexSearcher.searchLeaf does (validated by the checks) (recommended)

Three try/catch blocks in CachingCollector.java that mirror the two catches of IndexSearcher.java:835-855: an exception from other.getLeafCollector(context) skips that segment (no leaf collector, so no finish(), as IndexSearcher returns); an exception from the no-score collect loop ends the segment and the existing collector.finish() still runs; an exception from the scored loop ends the segment. It is not fully equivalent to IndexSearcher on the scored replay: segments there still end without finish() (LFV-180, option B), and setScorer stays outside the catch.

// NoScoreCachingCollector#replay
final LeafCollector collector;
try {
  collector = other.getLeafCollector(context);
} catch (CollectionTerminatedException _) {
  // there is no doc of interest in this reader context
  // continue with the following leaf
  continue;
}
collect(collector, i);
// both collect(...) loops (no-score loop shown)
try {
  for (int doc : docs) {
    collector.collect(doc);
  }
} catch (CollectionTerminatedException _) {
  // collection of this leaf was terminated early, continue with the following leaf
}

Trade-offs

  • Behaviour change. A caller that catches the exception around replay today gets the complete result instead of a truncated one; no documented contract supports relying on the escape, which is undocumented and stackless.
  • setScorer. The scored replay calls setScorer before the try; in IndexSearcher it runs inside scorer.score(...), under the catch. Moving it into the try would mirror that exactly; LeafCollector#setScorer does not document the exception, so it was left out (not built).
  • finish() on the scored replay is still missing: that is LFV-180 (option B).
  • Cost. One try per segment, nothing per document. Conflict. Open PR #15210 edits other lines of the file.

What the checks validated

  • Stock: the minimal test fails with assertions on and off. With the fix: it passes with assertions on and off, and the index-sort scenario passes 2 of 2 (top 10 ts 0..9).
  • TestCachingCollector, TestCachingCollectorManager, TestMultiCollector, TestMultiCollectorManager, TestIndexSearcher (39 tests with the new one, 1 skipped) and the whole grouping module (56 tests): 0 failures, assertions on and off. The whole core module: 748 classes, 8,897 tests, 0 failures, 322 skipped (748 is the number of core Test*.java files, so no copied test ran).
  • ecjLint and forbiddenApis (main and test) pass; checkGoogleJavaFormat passes for CachingCollector.java and asks only to wrap the test's 105-column comment line.

The same, plus LFV-180's two finish() lines, as one patch (validated by the checks)

Option A plus collector.finish() after the scored replay's try/catch, so both replay paths end every replayed segment with finish() "including when a CollectionTerminatedException is thrown" (LeafCollector.java:150-151), plus super.finish() in NoScoreCachingLeafCollector#finish (LFV-180's fix for the wrapped first pass). +24/-7 in one file.

Trade-offs

One PR for two issues; reviewers may prefer them separate.

What the checks validated

This doc's test, LFV-180's TestCachingCollectorFinish (facet), the closest core suites (39 tests, 1 skipped) and the grouping module (56 tests) pass with assertions on. The whole core module was run for option A only.

Document the limitation (not recommended)

A javadoc note such as "replay does not support collectors that throw CollectionTerminatedException".

Trade-offs

Rules out an index-sorted TopFieldCollector's early termination in a second pass: callers would have to turn it off with an Integer.MAX_VALUE threshold or re-run the search, since catching the exception gives wrong hits. It also contradicts LeafCollector#collect.

Downstream note

By code reading, Solr's grouping cache and Lucene's GroupingSearch replay only into second passes that never stop early, so neither is affected.

How it was found

A bounded-exhaustive check: a harness ran the real collector managers on every index of 1-3 segments with 1-6 documents under every slicing and compared each result with a plain search. Replay into an index-order TopFieldCollectorManager threw CollectionTerminatedException 271,021 times; the finder then reduced it to a JUnit.

The method in full107 words

Method: bounded-exhaustive translation validation (semi-formal). The harness of a hunt unit (concurrent collector managers) runs the real collector managers on every index of 1-3 segments x 1-6 documents (258 shapes) under every distinct IndexSearcher slicing (3,750), sequentially and with a 3-thread pool, and compares each result with a plain IndexSearcher.search. Among its CachingCollectorManager checks, the failure kind "replay into TopFieldCollectorManager(INDEXORDER, 1, 0) threw CollectionTerminatedException" appeared 271,021 times in the run with assertions on, in every slicing. The finder reduced it to a one-segment public-API JUnit on the stock main jars and a release-compatible SimpleCollector variant; this doc's test is a one-document, one-method version of those.

Prior reports, history and checks

Prior reports

No prior report found: 136 query lines on 2026-10-26 (GitHub issues and PRs, commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, downstream trackers, the web), refreshed on 2026-10-30. Not fixed on upstream main 2f424fd428da. The same omission was reported for three other drivers: upstream fixed two, and the third report stayed open until its class was removed.

Searches5 searches

Related, not reports of this bug: LUCENE-6326 / LUCENE-6772 (#7387, #7830; MultiCollector, fixed in 5.4), #12609 (SuggestIndexSearcher, merged 2023) and LUCENE-6337 (#7398, ToParentBlockJoinIndexSearcher, still open; the class was removed in 2017) are the same omission at other drivers; #16247 (added CachingCollectorManager) and #12380 (added finish()) are the two recent changes to replay, and their reviews never mention the exception; #12270 made the exception stackless because it is always ignored; #5436 (LUCENE-4370) is a 2012 comment on code that no longer exists; #10401 (LUCENE-9361, open) proposes removing CachingCollector; open PR #15210 edits other lines of CachingCollector.java (a textual conflict, not a fix). In the registry, LFV-180 (the missing finish() in the scored replay and in the wrapped first pass) touches the same method. Search limit: the extended prior-report tool run stopped after 8 of its 20 GitHub queries (GitHub's search rate limit); the GraphQL searches, on a separate quota, covered every term of the unrun queries.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, lucene-solr, solr, lucenenet, Elasticsearch, OpenSearch, all of GitHub)2026-10-26a scripted prior-report search (16 issue/PR, 3 commit and 2 downstream-repository queries) and 58 GraphQL searches over class, method, exception and symptom names; 33 hits readno report of this bug; the related items above
Open apache/lucene PRs2026-10-26changed files of all 413 open PRsnone changes the replay loops; #15210 edits other lines of the file
Mailing lists, JIRA archive, web2026-10-2620 list queries (dev@, java-user@, issues@, commits@), 4 JIRA and 8 web queries, direct subject listingsno report; three list threads on throwing the exception from getLeafCollector, the LUCENE-6772 change and timeouts are context
Git history2026-10-26git log -S/-G CollectionTerminatedException and --grep over all branchesno commit adds or removes a handler in replay
Refresh2026-10-304 keyword searches, CachingCollector and CollectionTerminatedException items by update date, commits to both replay files on upstream main, the state of #15210 and #16857nothing new; upstream main 2f424fd428da unchanged in both files; #16857 merged 2026-10-29 (moves the index-sort throw, see Suggested fix)

History

Introduced with the exception itself: LUCENE-4862 (jpountz, 2013-03-21, first released in 4.3.0) taught only IndexSearcher to catch it. Every release from 4.3.0 to 10.6.0 and main is affected (13 releases and main executed, the rest by source history); 4.2.1 has no such exception. CachingCollectorManager (#16247, new in 10.6.0) inherits it.

Versions tested6 rows

986444de446b (trunk) / 65b70503ab16 (branch_4x), LUCENE-4862 (migrated issue #5927, pre-GitHub, no PR), added CollectionTerminatedException and the catch around scoring in IndexSearcher only; CachingCollector.replay dated from 3.2 (LUCENE-3102). LUCENE-4858 (a5c3b88fe7ae, 2013-04-09, also 4.3.0) added the catch for setNextReader, today getLeafCollector, again in IndexSearcher only. LUCENE-5527 (85642f59ddf0, 2014) rewrote replay into today's per-segment shape, #12380 (8811f31b9ca6, 2023) added finish() to the no-score replay, and #16247 (3afffeef5a8d, gaobinlong, 2026-07-08) added CachingCollectorManager.replay; none added a handler.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and standalone, assertions on and off); the index-sort scenario returns [1000..1009]
10.6.0 affectedthe minimal test fails, assertions on and off; the index-sort scenario fails (release jars)
10.5.2, 9.12.3 affectedthe minimal test (10.5.2) and the index-sort scenario (both) fail
4.3.0 to 10.5.2 (12 releases run) affectedthe follow-up's history probe fails 4 of 4 cases on 4.3.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2
4.2.1 no such exceptionthe probe does not compile; with an ordinary exception both drivers behave the same
main 645d4fe14a20 with the fix not affectedthe minimal test and the index-sort scenario pass; core caching tests, grouping and the whole core module pass

Independent checks

A cross-model refute review (executed), independent verifiers and an adjudication rate it real; each rates the intended-behaviour case at most moderate. This doc's test was run on main, 10.6.0 and 10.5.2, and with the fix.

Checks and verdicts281 words

Every check re-ran a repro on stock jars; the reviewer from another model family (OpenAI GPT-5.6) executed its own test in the refute lens and only read code in the works-as-designed lens.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateHarness: 271,021 failing checks; JUnit fails 2 of 2 on main (assertions on and off); the legacy variant fails on main, 10.5.2 and 9.12.3; passes with the fix.
cross-model refute review real, highOwn SimpleCollector test fails on main, the IndexSearcher control passes; intended case moderate; noted the scored replay's missing finish() (LFV-180).
Independent reproducer real11 of 11 own tests fail (collect and getLeafCollector paths, both cache modes, sequential and executor manager), 110 of 110 over 10 iterations, 7 of 7 on 10.5.2 and 9.12.3; pass with its own per-segment catch.
Works-as-designed review real, high (reading only)Intended case moderate; a separate run re-ran the finder's repro (2 of 2 fail).
Devil's advocate realIndex-sort probe fails on main, 10.5.2 and 9.12.3; a caller-side catch gives [1000..1009]; no comment, test or discussion chose propagation.
adjudication confirmed, highThe independent reproduction's tests with a new seed: 11 of 11 fail (assertions on and off), 11 of 11 pass with the independent reproduction's fix; the intended-behaviour check's probe 2 of 2 fail.
this doc realMinimal test on main 645d4fe14a20 (gradle and standalone, assertions on and off), 10.6.0, 10.5.2 and a scratch build of upstream main 2f424fd428da; the index-sort scenario on main, 10.6.0 and upstream main; fix A (test, scenario, closest suites, grouping, the whole core module, lint) and option B in a scratch worktree.

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

Finding record

LFV-154, snapshot of 2026-11-06

Registry title
CachingCollector#replay (and CachingCollectorManager#replay) lets a CollectionTerminatedException from the second-pass collector escape to the caller instead of ending only that leaf as IndexSearcher does, so replaying into an early-terminating collector, such as a TopFieldCollector sorted by the index sort, throws
Area
search / CachingCollector replay (CachingCollector, CachingCollectorManager)
Priority
Low, rank 709: CachingCollector replay lets a CollectionTerminatedException from an early-terminating second pass escape (wrong top hits if the caller catches it), loud and only for applications that drive replay themselves.
Impact class
crash
Impact note
Hits only applications that call CachingCollector#replay (released, @lucene.experimental) or CachingCollectorManager#replay (new in 10.6.0, released 2026-10-29) themselves with a second pass that ends a leaf early: a TopFieldCollector sorted by index order or by a prefix of the index sort with a finite totalHitsThreshold (IndexSearcher's own default is 1000) once a segment has more hits than the threshold, a MultiCollector whose children all terminate, or a custom collector that throws the exception as LeafCollector#collect documents; for them it fires deterministically on ordinary data with the default configuration, -ea and -da: replay throws a stackless CollectionTerminatedException, stops at the first terminated leaf (CachingCollectorManager also skips the remaining slices and reduce()), and gives silently wrong top hits only if the caller catches the exception around replay; nothing is corrupted, and in-tree GroupingSearch is not affected (its second-pass collectors use Integer.MAX_VALUE thresholds, by code reading).
Summary
You run a query once through a CachingCollector (or CachingCollectorManager) and then replay the cached hits into a second-pass collector, expecting the same result as running that collector through IndexSearcher. When the second-pass collector stops a segment early by throwing CollectionTerminatedException, which is how LeafCollector#collect documents early termination and what a stock TopFieldCollector sorted by the index sort does once a segment has more than 1000 hits, IndexSearcher simply moves on to the next segment, but replay lets the exception escape to the caller, without a stack trace. Catching it around replay is no workaround: the later segments are never replayed, so in the repro the top 10 come back as [1000..1009] instead of [0..9].
Introduced
65b70503ab16 (branch_4x, svn r1459408, merged from trunk 986444de446b, r1459400; LUCENE-4862 'Ability to terminate queries on a per-segment basis', GitHub mirror #5927, by jpountz, reviewed on the issue by shaie; pre-GitHub, no PR): it added CollectionTerminatedException and a catch around scoring in IndexSearcher only, and left CachingCollector.replay (since 3.2, LUCENE-3102) without a handler [read: git show --stat lists CHANGES.txt, CollectionTerminatedException.java, Collector.java, IndexSearcher.java and TestEarlyTermination.java only; exec: release level, 4.2.1 has no such exception (probe does not compile; a baseline with an ordinary RuntimeException shows IndexSearcher and replay behaving the same), 4.3.0 fails 4 of 4]; the getLeafCollector path (then setNextReader) followed with a5c3b88fe7ae (branch_4x r1466057, trunk e2e3e2bfdafd; LUCENE-4858, #5923, by shaie with jpountz, 2013-04-09, also 4.3.0); carried forward unchanged in substance by 85642f59ddf0 (LUCENE-5527, 2014, replay rewritten to its current per-leaf shape) and 8811f31b9ca6 (#12380, 2023, added finish() to the no-score replay but no handler); the CachingCollectorManager entry point 3afffeef5a8d (#16247, by gaobinlong, merged 2026-07-08, first released in 10.6.0) inherits it [read]; no commit on any branch ever added or removed a handler in replay [read: git log --all -S/-G CollectionTerminatedException on both files] (2013-03-21 (LUCENE-4862, collect path); 2013-04-09 (LUCENE-4858, getLeafCollector path); 2026-07-08 (#16247, CachingCollectorManager entry point, first released in 10.6.0))
First affected version
4.3.0 [exec] for CachingCollector.replay (4.2.1 is the last release without the bug: CollectionTerminatedException does not exist there [exec: probe does not compile]); CachingCollectorManager.replay: 10.6.0 (released 2026-10-29; listed under 10.6.0 in CHANGES.txt)
Affected versions
every release from 4.3.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed. [exec] the follow-up's history probes (public-API main() programs, 4 cases: collect and getLeafCollector paths x cacheScores false and true) fail 4 of 4 on 4.3.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea, and -da on 9.12.3, 10.5.2 and main) and pass 4 of 4 with the fix shadowed on main; the finder's legacy JUnit fails 2 of 2 on 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 7 of 7 on 10.5.2 and 9.12.3; the intended-behaviour check's index-sort probe (stock TopFieldCollectorManager trigger) fails 2 of 2 on 9.12.3, 10.5.2 and main; the CachingCollectorManager JUnit fails 2 of 2 on main and does not compile on 10.5.2 (class absent). [read] the other releases from 4.3.0 to 10.5.2 by tag containment and the absence of any handler commit; branch_10x, branch_10_6 and branch_9_12 still have no handler (follow-up prior-report record, read, not executed). The stock-collector trigger was executed only on 9.12.3, 10.5.2 and main; older releases ran the minimal custom-collector probe. The 4.x-7.x runs used JDK 25, not the JDK those releases supported. Not executed: x86, a concurrent first pass with intra-segment partitions.
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: exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: scripted prior-report search original spec (16 GitHub issue/PR, 3 commit and 2 downstream-repo queries) and extended spec (20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 8 web queries), 58 GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and all of GitHub, direct JIRA and list-subject searches, the file lists of all 413 open apache/lucene PRs and git log -S/--grep over the full history: 136 query lines, 33 hits read, none the same bug; the finder's and the intended-behaviour check's own searches also found nothing); not fixed upstream: on main ea57360912ed, branch_10x, branch_10_6 and branch_9_12 replay still calls getLeafCollector and collect without a handler, and no open PR changes the replay loops (#15210 edits other lines of CachingCollector.java; #16857 moves the index-sort throw inside the TopFieldCollector code, and after its merge (2026-10-29) the repro still fails on e1faeba08bd, -ea and -da); related, not reports of this bug (the same bug class at other collector drivers, precedents for the fix): LUCENE-6326 / LUCENE-6772 (#7387, #7830, MultiCollector, fixed in 5.4), #12609 (SuggestIndexSearcher, merged 2023), LUCENE-6337 (#7398, ToParentBlockJoinIndexSearcher, open); also #5436 / LUCENE-4370 (a 2012 comment on CachingCollector's 4.x setNextReader handling, a different defect in code that no longer exists), #16247 and #12380 (the two recent changes to replay, whose reviews never mention the exception) and #12270 (why the exception is stackless); gaps: one scripted search run was stopped after 8 of 20 GitHub REST queries (GitHub's search rate limit; the GraphQL searches covered their terms), the bare 'replay' query read 50 of 52 results, closed unmerged PRs that edit CachingCollector.java without naming it cannot be found by text search, and forums only via web search
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner, and passes with the recommended fix (with the index-sort scenario, the closest suites, the grouping module and the whole core module). Also fails on the 10.6.0 and 10.5.2 release jars; the index-sort scenario fails on 10.6.0 and on a scratch build of upstream main 2f424fd428da (2026-10-30, after #16857 moved the index-sort throw). The hunt's earlier runs (finder, verifiers, adjudication, follow-up: 25 history runs over 4.2.1-10.5.2) used main ea57360912ed, where every cited source file except CHANGES.txt is identical.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:38 (@lucene.experimental), :98-104 (no-score per-leaf collect loop, finish() at :103, no handler), :106-118 (replay: getLeafCollector at :115 with no handler), :145-155 (scored per-leaf collect loop, no handler, no finish())
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:48 (@lucene.experimental), :114-130 (replay javadoc lists only IllegalStateException; per-slice cache.replay at :126, reduce at :129)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/CollectionTerminatedException.java:19-25 ('terminate collection of the current leaf'; 'IndexSearcher swallows this exception and never re-throws it'), :35-38 (fillInStackTrace: 'never re-thrown so we can save the expensive stacktrace')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100 (TOTAL_HITS_THRESHOLD = 1000), :831-863 (searchLeaf catches the exception from getLeafCollector at :837 and from scoring at :853, then calls finish())
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:75-79 (collect: the exception terminates 'the current segment' and IndexSearcher continues with the next leaf), :149-159 (finish is called 'including when a CollectionTerminatedException is thrown')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:91-101 (index-sort / index-order early termination throws at :100 once totalHits > totalHitsThreshold)
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:134, :142 (Integer.MAX_VALUE thresholds: GroupingSearch's second pass never terminates early)
  • a Lucene main checkout lucene/CHANGES.txt:438 (Lucene 10.6.0 section), :525 (GITHUB#16247 CachingCollectorManager)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:25-31, :98-118, :144-155, :345-354
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:24-31, :113-130
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, :733, :831-863
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:76-79, :149-159
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CollectionTerminatedException.java:19-26, :35-39
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:92-103; MultiCollector.java:128-143, :218-226
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:38; CachingCollectorManager.java:48
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:209; TopGroupsCollector.java:134, :142
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:78, :92-103; CachingCollectorManager.java:118-130
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:76-79, :149-159; CollectionTerminatedException.java:19-26, :35-39
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:25-31, :103, :345-354; CachingCollectorManager.java:24-27, :113-117
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:835-855; MultiCollector.java:128-134, :218-226
  • a Lucene main checkout (645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/document/SuggestIndexSearcher.java:74-77
  • GitHub apache/lucene #12380 review comments (gh api pulls/12380/comments, read 2026-10-30: jpountz 2023-09-28 on SuggestIndexSearcher.java)
  • a Lucene main checkout (645d4fe14a20) lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/utils/PostCollectionFaceting.java:37-52, :332-337, :424, :447-452 (no CollectionTerminatedException handler in the file)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:49, :72-79; lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollector.java:293-298, :303-313
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:149-151 (TODO); CachingCollector.java:351-352 (stale @throws IllegalArgumentException)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:835-862
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:456 (10.6.0 section), :543 (GITHUB#16247)

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 and the two-segment index-sort example.

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.