All findings · Lucene finding · LFV-180 · cachingcollector-drops-leafcollector-finish

CachingCollector never calls LeafCollector#finish on the collector it wraps or when it replays cached scores, so a FacetsCollector reports no hits

Since 9.8.0, a FacetsCollector wrapped in CachingCollector, or fed by its replay with cached scores, reports 0 hits for a query that matches 20 documents, with no error. CachingCollector never passes the per-segment finish() call on, and FacetsCollector records a segment's hits only in finish().

Summary

Priority
Low, rank 560Since 9.8.0 CachingCollector never forwards LeafCollector#finish, so a FacetsCollector behind it silently reports no hits, a natural combination of an experimental API that no known user builds and that -ea tests on several segments catch.
Impact
silent wrong results
Since
9.8.0 (#12380, 2023)introduced 2023-06-30 · about 3 years
Affected
9.8.0 to 10.6.0 and main (10.6.0 by reading); CachingCollectorManager (new in 10.6.0, released 2026-10-29) 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 sits between IndexSearcher and the collector it wraps, records each segment's hits, and can replay them into another collector later. It drops the per-segment finish() call in two places.

Background: how a collector sees a segment

  1. For each segment, IndexSearcher asks the collector for a leaf collector, calls collect(doc) for every match, and then calls finish() once the segment is done.
  2. FacetsCollector buffers a segment's matches in collect and adds them to getMatchingDocs() only in finish().
  3. CachingCollector wraps a collector: it forwards each call to it while recording the doc IDs (and, with cacheScores, the scores), so replay(other) can feed the same hits to another collector without rerunning the query.
Summary card for CachingCollector and LeafCollector#finish: a query matching 20 documents in one segment; a FacetsCollector wrapped in CachingCollector, and one fed by the replay with cached scores, each report 0 hits instead of 20 because finish() never reaches them; the two-line fix forwards it.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 20 documents into one segment, all matching f:a. Wrap a FacetsCollector in CachingCollector.create(fc, true, 1000) (cache doc IDs and scores) and search with it. You expect 20 hits in getMatchingDocs(), as without the cache.
  2. Instead it reports 0. IndexSearcher calls finish() on each segment's leaf collector, but the cache's per-segment wrapper overrides finish() for its own bookkeeping and never forwards it. FacetsCollector adds a segment's hits to getMatchingDocs() only in finish().
  3. Replay the cache into a new FacetsCollector(true): again 0 instead of 20. The replay with cached scores never calls finish(); the replay of a cache without scores does, and gives 20.
  4. No error with assertions off. With assertions on and two or more segments, FacetsCollector throws an AssertionError instead. CachingCollectorManager (main and 10.6.0, released 2026-10-29) behaves the same. After a cache overflow, later segments do get finish(), so results turn partial instead of empty.

Who is affected

Applications that put a finish-dependent collector (FacetsCollector, BlockGroupingCollector, the TermGroupFacetCollectorManager collectors, the sandbox HistogramCollector or their own) behind CachingCollector (since 9.8.0) or CachingCollectorManager (10.6.0 and main), or replay a score cache into one. For them it fires on every query. Lucene's GroupingSearch is not affected: its collectors do not implement finish().

Mechanism, terms and numbers522 words

CachingCollector lets an application run a query once, keep its hits and later replay them into another collector. Two calls to LeafCollector#finish(), the hook IndexSearcher calls once a segment is collected, are missing in it, both introduced by #12380 (9.8.0), the change that added the hook: it wrote the wrapper's non-forwarding finish() override and gave only the no-score replay a finish() call. The per-segment wrapper NoScoreCachingLeafCollector extends FilterLeafCollector, whose finish() forwards to the wrapped collector, but overrides finish() for its own bookkeeping without super.finish() (CachingCollector.java:214-221 on main 645d4fe14a20), so the wrapped collector never gets finish() while the cache is live. And the replay with cached scores, ScoreCachingCollector#collect (:144-155), overrides the no-score replay (:98-104) and leaves out its collector.finish() (:103). The same commit moved FacetsCollector's per-segment MatchingDocs into finish() (FacetsCollector.java:116-128), so a FacetsCollector behind the cache, or replayed from a score cache, reports no hits: 0 of 20 in the repro, with no error unless assertions are on and a slice has two or more segments. BlockGroupingCollector loses each segment's last group the same way. It is a regression from 9.7.0, affects every release from 9.8.0 to 10.6.0 (10.6.0 by reading) and main, and the new CachingCollectorManager (in 10.6.0, released 2026-10-29) inherits it. Forwarding the call at both sites (two lines) fixes it; the core caching tests and the grouping and facet modules pass with the fix.

The two missing calls (main 645d4fe14a20)

  1. Wrapped first pass, both cacheScores modes. While the cache is live, getLeafCollector returns a NoScoreCachingLeafCollector (or its subclass ScoreCachingLeafCollector) around the wrapped leaf collector (CachingCollector.java:80-90). It extends FilterLeafCollector, whose finish() is in.finish() (FilterLeafCollector.java:51-54), and its collect forwards every hit (CachingCollector.java:188-206). But it overrides finish() with its own bookkeeping only:
   @Override
   public void finish() {
     if (!hasCache()) {
       collector.invalidate();
     } else {
       postCollect();
     }
   }

(CachingCollector.java:214-221). No super.finish(), so the wrapped collector's finish() never runs.

  1. Replay with cached scores. replay(other) calls collect(collector, i) per cached segment (:106-118). The no-score collect ends with collector.finish() (:103); ScoreCachingCollector#collect overrides it to replay doc IDs with scores and ends without it (:144-155).

What the user sees

  • FacetsCollector records MatchingDocs only in finish() (FacetsCollector.java:116-128), so getMatchingDocs() stays empty: 0 of 20 hits. With assertions on and a second segment in the slice, doSetNextReader fails assert docsBuilder == null (:107) first.
  • BlockGroupingCollector processes a segment's last group in finish() (BlockGroupingCollector.java:497-502), so that group is lost; with a full queue a returned group can carry another matched document's sort value (executed security probe).
  • When the cache overflows inside a segment, the following segments get the unwrapped leaf collector (CachingCollector.java:87-88), which IndexSearcher finishes (IndexSearcher.java:862). The result is then partial instead of empty (per-leaf finished=[false, true]).
  • CachingCollectorManager builds one CachingCollector per slice (CachingCollectorManager.java:82-91) and inherits both halves; its javadoc example takes the first-pass result directly (:38). Executed through the manager sequentially and with a thread pool; the manager with intra-segment partitions was not run.
Exposure and likelihood173 words
  • Opt-in. CachingCollector and CachingCollectorManager are @lucene.experimental expert APIs (CachingCollector.java:38, CachingCollectorManager.java:48). Within that usage the default settings suffice, and no scale is needed.
  • Silent. With assertions off the facet result is simply empty (or holds only the segments after an overflow); nothing is persisted. With assertions on and 2+ segments in a slice, tests see an AssertionError instead.
  • Grouping. BlockGroupingCollector drops each segment's pending last group; with a full queue a group can come back with another matched document's sort value (executed probe, main and 10.5.2 only: 408 checks on main with assertions off, 433 with them on, 408 on 10.5.2; 0 with the fix).
  • Releases. Every release from 9.8.0 to 10.6.0 (10.6.0 by reading) and main; CachingCollectorManager (#16247) first shipped in 10.6.0 (released 2026-10-29).
  • Likelihood: rare. Lucene's own GroupingSearch and Solr's grouping cache (by code reading) put no finish-dependent collector behind the cache; saved GitHub code searches found no CachingCollector use on the Elasticsearch and OpenSearch default branches. The recorded searches found no report in the three years since 9.8.0.

Minimal repro

The test shows that CachingCollectorManager (cacheScores=true) never calls LeafCollector#finish on the wrapped first-pass FacetsCollector, nor on the FacetsCollector the scored cache is replayed into, so both report no hits (1-document index, MatchAllDocsQuery).

TestCachingCollectorFinish.java (JUnit test)53 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.facet;

import java.util.List;
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.search.CachingCollectorManager;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCachingCollectorFinish extends LuceneTestCase {

  public void testFacetsCollectorBehindScoreCache() 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)) {
        CachingCollectorManager<FacetsCollector, FacetsCollector> caching =
            new CachingCollectorManager<>(new FacetsCollectorManager(), true, null, 1000);
        FacetsCollector firstPass =
            new IndexSearcher(reader).search(new MatchAllDocsQuery(), caching);
        FacetsCollector replayed = caching.replay(new FacetsCollectorManager());
        // Expected: first pass and scored replay each see the 1 hit; actual: [0, 0] (no finish()).
        assertEquals(List.of(1, 1), List.of(hits(firstPass), hits(replayed)));
      }
    }
  }

  private static int hits(FacetsCollector fc) {
    return fc.getMatchingDocs().stream().mapToInt(FacetsCollector.MatchingDocs::totalHits).sum();
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-180/, 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/facet/build/libs/lucene-facet-$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.facet.TestCachingCollectorFinish
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected hits [first pass, scored replay] = [1, 1], as a plain search gives; actual: [0, 0] (expected:<[1, 1]> but was:<[0, 0]>), with -ea and -da.

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

More files, environment, full output36 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, 1]> but was:<[0, 0]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.facet.TestCachingCollectorFinish.testFacetsCollectorBehindScoreCache(TestCachingCollectorFinish.java:45)

Why it looks unintended

  • LeafCollector#finish runs once a segment "has finished collecting successfully" (LeafCollector.java:149-159), and create() calls the wrapped collector "the Collector to wrap and delegate calls to".
  • The replay without scores already calls finish() (CachingCollector.java:103), added by the same commit (#12380).
  • Behaviour regression from 9.7.0 (supporting evidence: the hook is new in 9.8.0); delivery that varies with cache overflow cannot be a design.
The full argument151 words
  • Contract. LeafCollector#finish (LeafCollector.java:149-159); CachingCollector.create: "the Collector to wrap and delegate calls to" (CachingCollector.java:301, :320); the leaf wrapper is a FilterLeafCollector, whose finish() delegates (FilterLeafCollector.java:51-54).
  • Same commit, inconsistent. #12380 added the forwarding FilterLeafCollector#finish and collector.finish() in the no-score replay, but not in the scored replay or the wrapper's override. Its 18 review comments and issue #12375 never discuss CachingCollector's forwarding.
  • The test framework asserts it. AssertingCollector checks that the previous leaf was finished (AssertingCollector.java:62); wrapping one in a CachingCollector trips that check (executed).
  • Precedent. The missing or double finish() in DrillSidewaysScorer was fixed as a bug (#12640, #12642, #12853); as gsmiller wrote on #12853, "We should consider it a bug for MatchingDocs to not be populated". In 2011 LUCENE-3102 (#4175) fixed "CachingCollector.replay was failing to call setScorer per-segment".
  • Why tests missed it. TestCachingCollector and TestCachingCollectorManager only use collectors without finish().
The devil's-advocate check136 words

Strongest case (the intended-behaviour check and the works-as-designed cross-model review): the classes are @lucene.experimental; the replay javadoc promises only to replay "the cached doc IDs (and scores)" (CachingCollector.java:345-354); the only in-tree user, GroupingSearch, needs no finish(); and no test caches or replays into a finish-dependent collector.

Why it fails: the no-score replay already calls finish(); the first pass is documented as delegating; delivery that changes with cache overflow is no contract; and experimental status allows API changes, not silently empty results. The cross-model reviews and the intended-behaviour check rated the case weak; the independent reproduction rated it weak for the first pass and weak-to-moderate for the replay. For the scored replay the contract comes from consistency with the no-score replay and the general LeafCollector contract, not from explicit replay wording.

Suggested fix

Recommended Forward finish() at both sites

Forward the call at both sites (CachingCollector.java, +3/-1): super.finish() at the start of NoScoreCachingLeafCollector#finish, which then declares IOException, and collector.finish() at the end of ScoreCachingCollector#collect. The test passes; the core caching tests and the grouping and facet modules pass, with assertions on and off.

// ScoreCachingCollector#collect (replay with cached scores)
       collector.collect(docs[j]);
     }
+    collector.finish();
   }
// NoScoreCachingLeafCollector#finish (wrapped first pass)
   @Override
-  public void finish() {
+  public void finish() throws IOException {
+    super.finish();
     if (!hasCache()) {
All options and trade-offs3 options ยท 338 words

Two options were built and run in a scratch worktree of 645d4fe14a20; a documentation-only change is not recommended.

Forward finish() at both sites (validated by the checks) (recommended)

super.finish() in the leaf wrapper's finish() (first pass, both cacheScores modes) and collector.finish() after the scored replay loop. The wrapped collector then gets finish() exactly once per segment, whether the cache is live or overflowed (after an overflow IndexSearcher finishes the unwrapped leaf collector, as before).

// ScoreCachingCollector#collect (replay with cached scores)
       collector.collect(docs[j]);
     }
+    collector.finish();
   }
// NoScoreCachingLeafCollector#finish (wrapped first pass)
   @Override
-  public void finish() {
+  public void finish() throws IOException {
+    super.finish();
     if (!hasCache()) {

Trade-offs

The override must declare IOException, as FilterLeafCollector#finish already does. Forwarding first means a throwing wrapped finish() skips the cache's bookkeeping for that segment; the search fails with that exception anyway, and putting super.finish() after the bookkeeping works too.

What the checks validated

  • Stock + test: fails with assertions on and off; + fix: passes with assertions on and off.
  • + fix: core TestCachingCollector, TestCachingCollectorManager, TestMultiCollector, TestMultiCollectorManager, TestIndexSearcher (38 tests, 1 skipped), the whole grouping module (56 tests) and the whole facet module (307 tests), 0 failures, assertions on and off; :lucene:core:check :lucene:facet:check -x test passes.
  • Each line alone leaves the test failing ([20, 0], [0, 20]). Not run: the full test suite.

Same first-pass line, one finish() call in replay() for both replay paths (validated by the checks)

Move collector.finish() from NoScoreCachingCollector#collect into the replay loop, right after collect(collector, i) (+3/-2 with the first-pass line).

Trade-offs

One call site, so a future override of collect cannot drop it again; a slightly larger diff that moves an existing line.

What the checks validated

The test, TestCachingCollector* (8 tests) and the grouping module (56 tests) pass, assertions on.

Document that finish() is not forwarded

A javadoc note on CachingCollector and CachingCollectorManager.

Trade-offs

Leaves facets behind the cache silently empty and contradicts "the Collector to wrap and delegate calls to". Not recommended.

Downstream note

Solr's grouping cache (by code reading) puts no finish-dependent collector behind CachingCollector, and saved GitHub code searches found no use of it on the Elasticsearch and OpenSearch default branches.

How it was found

A bounded-exhaustive check: a harness ran the real collector managers, including CachingCollectorManager with FacetsCollectorManager as first and second pass, on every index of 1-3 segments with 1-6 documents under every slicing, and compared each result with a plain search. The hypothesis came from reading #12380; the harness confirmed it in every slicing, then it was reduced to a JUnit.

The method in full113 words

Method: bounded-exhaustive translation validation (semi-formal). While scoping the harness of a hunt unit (concurrent collector managers), the finder read #12380 and suspected that CachingCollector loses LeafCollector#finish. The harness 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. It witnessed the bug in every slicing: the cached-first-pass failure kinds 360,000 times in the final run. The finder then reduced it by hand to a one-segment public-API JUnit on the stock main jars and a release-compatible variant; this doc's test is a one-method version of those.

Prior reports, history and checks

Prior reports

No prior report found: 217 queries 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-29. Not fixed upstream: main, branch_10x and branch_10_6 carry both sites, and a scan of the 413 PRs open on 2026-10-26 found none touching them.

Searches5 searches

Related, not reports of this bug: #12380 introduced both halves (it wrote the non-forwarding override and added finish() to the no-score replay only; its reviews never discuss CachingCollector); #12375 lists CachingCollector among the collectors that could use the hook; #16247 added CachingCollectorManager (its reviews never mention finish()); #12640, #12642 and #12853 fixed the same class of bug in DrillSidewaysScorer; #4175 (LUCENE-3102) is the 2011 replay precedent; #10401 (LUCENE-9361, open) proposes removing CachingCollector; open PR #15210 edits the same inner class (a textual conflict, not a fix).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/solr, Elasticsearch, OpenSearch)2026-10-26three scripted prior-report searches and 41 GraphQL searches: class and inner-class names, "LeafCollector#finish", FilterLeafCollector finish, "docsBuilder == null", cacheScores, replay finish; bodies and reviews of 17 apache/lucene items readno report of either half
Open apache/lucene PRs2026-10-26changed files of all 413 open PRsnone touches either site; #15210 edits the same inner class
Mailing lists and JIRA archive2026-10-2627 list searches (dev@, java-user@, issues@, commits@, general@, Solr lists), 4 JIRA searchesno report; the 2023 dev@ threads on finish() semantics are context
Web2026-10-267 web searchesnothing relevant
Refresh2026-10-29upstream branch heads; CachingCollector issues and PRs by update date; three keyword searches over items updated since 2026-10-25 (415 PRs open by then; the changed-file scan was not repeated)main still 645d4fe14a20, both sites unchanged on branch_10x and branch_10_6, no new item

History

Introduced by #12380 (jpountz, merged 2023-06-30, first released in 9.8.0), which added LeafCollector#finish. Every release from 9.8.0 to 10.6.0 (10.6.0 by reading) and main is affected; 9.7.0 and earlier are not. CachingCollectorManager (#16247, new in 10.6.0, released 2026-10-29) inherits it.

Versions tested6 rows

#12380 "Add a post-collection hook to LeafCollector." is 8811f31b9ca6 on main and de07bdd88dd1 on branch_9x, the same patch. It added LeafCollector#finish and the forwarding FilterLeafCollector#finish, replaced CachingCollector's old postCollection() bookkeeping with the non-forwarding NoScoreCachingLeafCollector#finish, added finish() to the no-score replay only, and moved FacetsCollector's MatchingDocs recording into finish(). Before it, FacetsCollector saved its segments without the hook, which works through the cache. #16247 (gaobinlong, merged 2026-07-08) added CachingCollectorManager, listed under 10.6.0.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails, [0, 0], assertions on and off
10.0.0, 10.5.2 affectedthe test fails, [0, 0] (release jars)
9.8.0 to 10.5.2 (23 releases) affectedthe follow-up's history probe gets 12 of 12 checks wrong on each; a release-compatible JUnit fails on all of them
#12380 commits 8811f31b9ca6, de07bdd88dd1 vs their parents bisectbuilt from source: 12 of 12 wrong; the parents 40ee6e583e18, aa0474750584 get every check right
8.0.0 to 9.7.0 (37 releases) not affectedno finish hook; every probe check right (20 of 20 hits on 9.7.0)
with the fix (main 645d4fe14a20) not affectedthe test passes; core caching tests, grouping and facet modules pass

Independent checks

A cross-model refute review (executed), independent verifiers and an adjudication all rate it real; the cross-model reviews call the intended-behaviour case weak. This doc's test was run on main, 10.0.0 and 10.5.2, and with the fix.

Checks and verdicts216 words

Every check re-ran a repro on the stock jars; the reviewer from another model family (OpenAI GPT-5.6) executed its own tests 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 witnessed it in every slicing; JUnit fails 2 of 4 on main (-ea, -da), the release variant fails on 9.8.0, 9.12.3, 10.0.0, 10.5.2 and main and passes on 9.7.0.
cross-model refute review real, highOwn finish-counting tests fail 2 of 4 each for CachingCollector and CachingCollectorManager; pass with the fix.
Independent reproducer real13 of 19 own tests fail on main (-ea, -da), 19 of 19 pass with its fix; overflow probe finished=[false, true]; AssertingCollector probe trips.
Works-as-designed review real, high (reading only)Intended argument weak; a separate run re-ran the repro (2 of 4 fail).
Devil's advocate realNo comment, test, review or list post chose to skip finish(); 9.7.0 passes, 10.5.2 fails.
adjudication confirmed, highRe-ran the independent reproduction's tests: 13 of 19 fail, 19 of 19 with the fix.
this doc reproduced, fix validatedOne-method test on main, 10.0.0, 10.5.2; fix A and 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,547 words

Finding record

LFV-180, snapshot of 2026-11-06

Registry title
CachingCollector (and CachingCollectorManager) never calls LeafCollector#finish on the collector it wraps, nor on the second-pass collector when it replays cached scores, so finish-dependent collectors such as FacetsCollector silently report no hits
Area
search / CachingCollector first pass and score-caching replay (CachingCollector, CachingCollectorManager)
Priority
Low, rank 560: Since 9.8.0 CachingCollector never forwards LeafCollector#finish, so a FacetsCollector behind it silently reports no hits, a natural combination of an experimental API that no known user builds and that -ea tests on several segments catch.
Impact class
silent wrong results
Impact note
Hits applications that combine CachingCollector (released since 9.8.0, @lucene.experimental) or CachingCollectorManager (new in 10.6.0, released 2026-10-29) with a collector that finalizes its per-leaf results in LeafCollector#finish, either as the wrapped first pass (both cacheScores modes) or as the target of a score-caching replay: with the default configuration and assertions off, FacetsCollector then reports no MatchingDocs at all (0 of 20 hits in the repro) and BlockGroupingCollector drops each leaf's pending last group and can return a wrong group order and sort values (executed by the security probe), while with assertions on and two or more leaves in a slice FacetsCollector throws AssertionError ('assert docsBuilder == null') instead; once the cache overflows, only the leaves after the overflowing one get finish, so results turn partial rather than empty; no scale is needed, nothing is persisted, and the no-score replay and in-tree GroupingSearch (whose collectors do not implement finish) are not affected.
Summary
You wrap a FacetsCollector, or any collector that does its per-segment work in LeafCollector#finish, in a CachingCollector or CachingCollectorManager so you can run the query once and replay the hits later, or you replay a score-caching cache into such a collector, and you expect the same facet counts as a plain IndexSearcher search. Instead the collector reports no hits: CachingCollector's per-leaf wrapper overrides finish() for its own bookkeeping and never passes the call on, and the score-caching replay never calls finish() at all, although the no-score replay does. In the repro a query that matches 20 documents gives 0 matching docs, with no error unless assertions are enabled.
Introduced
de07bdd88dd1 (branch_9x, released in 9.8.0) and 8811f31b9ca6 (main, released from 10.0.0), the same patch: #12380 'Add a post-collection hook to LeafCollector.' by jpountz, merged 2023-06-30 (closes #12375) [exec: lucene-core and lucene-facet built from source at both commits get 12 of 12 history-probe checks wrong; their parents aa0474750584 (branch_9x) and 40ee6e583e18 (main) get all checks right]; that commit added LeafCollector#finish and the forwarding FilterLeafCollector#finish, replaced CachingCollector's old postCollection() bookkeeping with a NoScoreCachingLeafCollector#finish override that does not call super.finish(), added collector.finish() to the no-score replay only (ScoreCachingCollector#collect, which overrides it, was not touched), and moved FacetsCollector's MatchingDocs recording into finish() [read: git show, git blame, git log -S/-G]; the CachingCollectorManager entry point 3afffeef5a8d (#16247, by gaobinlong, merged 2026-07-08, first released in 10.6.0) inherits it [exec on main] (2023-06-30 (#12380, both commits); 2026-07-08 (#16247, CachingCollectorManager entry point, first released in 10.6.0))
First affected version
9.8.0 [exec] (first main-line release: 10.0.0 [exec]); 9.7.0 is the last release without the bug [exec]: LeafCollector#finish did not exist there and FacetsCollector recorded MatchingDocs without it, which works through CachingCollector; CachingCollectorManager: 10.6.0 (released 2026-10-29; listed under 10.6.0 in CHANGES.txt)
Affected versions
every release from 9.8.0 to 10.5.2 (all 23: 9.8.0, 9.9.0-9.9.2, 9.10.0, 9.11.0-9.11.1, 9.12.0-9.12.3, 10.0.0-10.5.2), 10.6.0 (10.6.0 by reading) and main ea57360912ed. [exec] the follow-up's history probe (public-API main() program, lucene-core + lucene-facet, 20 docs as 1 and as 2 segments, 12 bug checks plus controls) gets 12 of 12 checks wrong on each of them with -ea, and also with -da on 9.8.0, 9.12.3, 10.0.0, 10.5.2 and main, and 0 of 12 with the fix sketch shadowed on main; the finder's release-compatible JUnit fails its 2 bug tests on all 23 releases and main (-da too on 9.8.0, 9.12.3, 10.5.2 and main); the CachingCollectorManager JUnit fails 2 of 4 on main with -ea and -da; the independent reproduction's tests fail on 10.5.2, 9.12.3 and 9.8.0, the cross-model review's finish-count test on 10.5.2 and 9.8.0, the intended-behaviour check's re-run on 10.5.2. Not affected [exec]: all 37 releases from 8.0.0 to 9.7.0 (probe; JUnit on 9.1.0-9.7.0) and both parent commits. [read] branch_10x and branch_10_6 (10.6.0, released 2026-10-29) carry both defect sites (their CachingCollector.java differs from main's only by a 'final' modifier); releases before 8.0.0 predate the hook. FacetsCollector and a finish-counting collector were executed on every version; BlockGroupingCollector only on main and 10.5.2 and the TermGroupFacetCollectorManager collector only on main (security probe). Not executed: x86, CachingCollectorManager with intra-segment partitions; the history runs used JDK 25.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that 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; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: 217 query lines, among them three scripted prior-report searches over GitHub issues/PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, 41 GraphQL searches over apache/lucene, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, 27 mailing-list full-text searches, 4 JIRA REST searches and 7 web searches, plus the bodies and review comments of 17 apache/lucene items, the file lists of all 413 open apache/lucene PRs and the local git history; none reports either half of the bug); not fixed upstream: on main ea57360912ed, branch_10x and branch_10_6 NoScoreCachingLeafCollector#finish still does not forward and the scored replay still ends without finish(), and no open PR touches either site (#15210 edits NoScoreCachingLeafCollector#setScorer in the same inner class and would conflict textually; #16133 changes FilterLeafCollector#competitiveIterator); related, not reports of this bug: #12380 (introduced it; its 18 review comments and 13 reviews never discuss CachingCollector), #12375 (lists CachingCollector among the collectors that could use the hook), #16247 (CachingCollectorManager; its reviews never mention finish), #4175 / LUCENE-3102 (2011 precedent: replay failing to call setScorer per segment was fixed as a bug), #5436 / LUCENE-4370 (2012 warning that delegating collectors must drive a future finish hook properly, old API), #12640 / #12642 / #12853 (missing or duplicate finish in DrillSidewaysScorer, fixed; gsmiller: 'We should consider it a bug for MatchingDocs to not be populated'), #12558 (what an empty MatchingDocs list did downstream, different cause), the 2023 dev@ threads 'LeafCollector#finish idempotency?' and the Solr 9.8.0 upgrade thread (finish runs once per leaf after successful collection), #10401 / LUCENE-9361 (open proposal to remove CachingCollector); gaps: GitHub's search rate limit was saturated (GraphQL search used instead), list search scores only the first 400 characters of unopened mails, private security reports and chat channels cannot be searched
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28, upstream main on 2026-10-29): the repro test through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock, with the fix, with each half of the fix and with the alternative fix; the 10.0.0 and 10.5.2 release jars (Maven Central, SHA-1 checked). The hunt's earlier runs (finder, verifiers, adjudication, follow-up: 116 history runs over 8.0.0-10.5.2, both #12380 commits and their parents) 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:25-38 (class javadoc, @lucene.experimental), :98-104 (no-score replay per-leaf loop, collector.finish() at :103), :145-155 (ScoreCachingCollector#collect, the scored replay, no finish()), :214-221 (NoScoreCachingLeafCollector#finish: own bookkeeping, no super.finish()), :301 and :320 ('the Collector to wrap and delegate calls to'), :346-354 (replay javadoc)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/FilterLeafCollector.java:30 (@lucene.experimental), :51-54 (finish forwards to in.finish()); LeafCollector.java:149-159 (finish: called once the leaf has finished collecting successfully, 'e.g. to convert facet counts on leaf ordinals to facet counts on global ordinals'); IndexSearcher.java:862 (searchLeaf calls leafCollector.finish())
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:25-41 (wraps a delegate manager, replay 'without re-running the query', example 'R1 firstResult = searcher.search(query, caching)'), :48 (@lucene.experimental), :118-130 (replay into per-slice caches)
  • a Lucene main checkout lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:106-114 (doSetNextReader, 'assert docsBuilder == null' at :107), :116-128 (finish records MatchingDocs)
  • a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollector.java:497-502 (finish processes the leaf's last group); TermGroupFacetCollectorManager.java:240, :346 (leaf finish); lucene/suggest/src/java/org/apache/lucene/search/suggest/document/CompletionScorer.java:70-73 and TopSuggestDocsCollector.java:190-195 (why TopSuggestDocsCollector is out of reach); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:62
  • a Lucene main checkout lucene/CHANGES.txt:438 (Lucene 10.6.0 section), :525 (GITHUB#16247 CachingCollectorManager), :548 (GITHUB#16292 TermGroupFacetCollectorManager), :3329 (GITHUB#12380 LeafCollector#finish)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollector.java:80-90, :98-104, :106-118, :144-155, :188-206, :214-221
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/FilterLeafCollector.java:51-54; IndexSearcher.java:831-863
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:77-79, :106-114, :116-128
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/BlockGroupingCollector.java:497-502
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:24-48, :82-91
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:167-171; TermGroupFacetCollectorManager.java:240, :346; lucene/CHANGES.txt:543, :566
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:149-159; CachingCollector.java:103, :301, :320, :345-354; FilterLeafCollector.java:51-54
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:62
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:3362 (9.8.0 section), :543 (10.6.0 section)

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

Repro files

About the diagrams

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

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.