All findings · Lucene finding · LFV-880 · groupreducer-never-finishes-per-group-leaf-collectors

The grouping second pass never calls LeafCollector#finish on the per-group collectors, so a per-group FacetsCollector silently sees no documents

A custom GroupReducer whose per-group collector saves its results in finish(), such as a FacetsCollector, sees no documents: groups a and b report 0 hits instead of 3 and 2, with no error. The grouping second pass never calls finish() on them; a per-group FacetsCollector that worked on 9.7.0 has been broken since 9.8.0.

Summary

Priority
Low, rank 561It is the same finish()-not-forwarded regression as LFV-180, but it only hits users who subclass the experimental GroupReducer.
Impact
silent wrong results
Since
9.8.0 (#12380, 2023), when finish() was addedintroduced 2023-06-30 · about 3 years
Affected
9.8.0 to 10.6.0 and main
Status
documented
Found by
code reading while modelling the second-pass collector protocol, 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

The grouping second pass gives each group its own collector, but never tells those collectors that a segment is finished.

Background: two-pass grouping and finish()

  1. Two-pass grouping: FirstPassGroupingCollector finds the top groups; SecondPassGroupingCollector runs the query again and hands each matching document of those groups to that group's own collector, which a GroupReducer creates.
  2. A collector gets one leaf collector per segment. IndexSearcher calls its collect() for each match, then finish() once when the segment is done; the javadoc names compiling per-segment data, such as facet counts, as the typical use.
  3. Since 9.8.0, FacetsCollector turns a segment's matches into a MatchingDocs entry only in finish(); without it, getMatchingDocs() stays empty.
Diagram of the grouping finish() bug: 5 documents, a, b, a in segment 0 and b, a in segment 1, are routed to group a (3) and group b (2); IndexSearcher calls finish() after each segment on SecondPassGroupingCollector, which does not pass it on to the per-group collectors, so a collector that saves its results in finish() reports 0 for both groups; with the fix it reports 3 and 2.
The diagram follows the repro's example: 5 documents in 2 segments go to groups a and b, IndexSearcher calls finish() after each segment, the second pass does not pass it on, and both groups report 0; the 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 5 documents with a group key, a, b, a, b, a, in two segments: [a, b, a] and [b, a]. Run a two-pass grouping search: the first pass finds groups a and b; for the second pass you write a GroupReducer that gives each group its own collector, for example a FacetsCollector to count facets inside each group.
  2. Since 9.8.0 Lucene tells a collector that a segment is done by calling LeafCollector#finish(), and FacetsCollector saves a segment's matches only there. Run directly by IndexSearcher, such a collector sees all 5 documents. You expect group a's collector to report 3 and group b's 2.
  3. Instead both report 0, with no error. The documents do reach the groups (collect() runs 3 and 2 times), but finish() runs 0 times: GroupReducer replaces each group's leaf collector at the next segment without finishing it, and SecondPassGroupingCollector does not pass IndexSearcher's finish() on.
  4. With assertions on, a per-group FacetsCollector throws AssertionError on the second segment instead. The same code returned 3 and 2 on 9.7.0.

Who is affected

Applications that write their own GroupReducer whose per-group collector does its per-segment work in finish(), such as a FacetsCollector for facet counts per group, and run it through SecondPassGroupingCollector. They get empty results on every search, with no error. Stock GroupingSearch, TopGroupsCollector and DistinctValuesCollector are not affected; no public custom reducer was found.

Mechanism, terms and numbers699 words

In a two-pass grouping search, SecondPassGroupingCollector hands each matching document of the top groups to that group's own Collector, created by a GroupReducer. Since 9.8.0 (#12380) a collector learns that a segment is done through LeafCollector#finish(), which IndexSearcher calls once it has collected a segment and whose javadoc names compiling per-segment data, such as facet counts, as its typical use; FacetsCollector records a segment's MatchingDocs only there. The grouping second pass never passes finish() on: GroupReducer.setNextReader replaces each group's leaf collector at the next segment without finishing the old one (GroupReducer.java:84-88 on main 645d4fe14a20), GroupReducer has no finish hook, and SecondPassGroupingCollector does not override finish(), so it keeps LeafCollector's no-op default. So a per-group collector that publishes in finish() ends up empty, with no error: 5 documents in 2 segments, groups a (3) and b (2), both report 0. With assertions on, a per-group FacetsCollector throws AssertionError on the second segment. For that composition it is a regression: the same per-group FacetsCollector worked on 9.7.0, and every release from 9.8.0 to 10.6.0 and main is affected (a collector written against finish() itself never received it through GroupReducer, since the hook only exists from 9.8.0). Stock GroupingSearch, TopGroupsCollector and DistinctValuesCollector are not. Forwarding finish() from SecondPassGroupingCollector to each group's current leaf collector fixes it (+18 lines, validated with the grouping module's tests).

Terms

  • Second pass: SecondPassGroupingCollector (@lucene.experimental) extends SimpleCollector, so IndexSearcher uses the collector itself as the leaf collector. doSetNextReader calls groupReducer.setNextReader(ctx), and collect routes each document of a top group to groupReducer.collect(value, doc) and returns early for the others (SecondPassGroupingCollector.java:87-102 on main 645d4fe14a20, the call at :95, the early return at :90-92).
  • GroupReducer: public abstract class GroupReducer<T, C extends Collector>; "Each group is assigned a Collector returned by newCollector(), and LeafCollector#collect(int) is called for each document that is in a group" (GroupReducer.java:33-40). newCollector() is protected; setNextReader, collect and setScorer are public final. The class has no @lucene.experimental tag; its driver, SecondPassGroupingCollector, has one.
  • finish(): "Hook that gets called once the leaf that is associated with this collector has finished collecting successfully, including when a CollectionTerminatedException is thrown. This is typically useful to compile data that has been collected on this leaf, e.g. to convert facet counts on leaf ordinals to facet counts on global ordinals. The default implementation does nothing." (LeafCollector.java:149-159). IndexSearcher.searchLeaf calls it after collecting each segment, also when collection ended with a CollectionTerminatedException or a timeout (IndexSearcher.java:849-862; a segment whose getLeafCollector throws CollectionTerminatedException is skipped, with no leaf collector to finish, :836-840); MultiCollector and FilterLeafCollector forward it.

The bug

  1. GroupReducer.setNextReader runs collector.leafCollector = collector.collector.getLeafCollector(ctx) for every group (GroupReducer.java:84-88): the previous segment's leaf collector is overwritten without finish().
  2. GroupReducer has no finish method, and SecondPassGroupingCollector does not override finish(), so the call from IndexSearcher runs the empty default and reaches no per-group leaf collector, neither between segments nor after the last one.
  3. FacetsCollector.doSetNextReader asserts docsBuilder == null and starts a new builder (FacetsCollector.java:106-114); only finish() adds the segment's MatchingDocs (:116-128). Through GroupReducer, with assertions off every group's FacetsCollector has no MatchingDocs; with assertions on the second segment trips the assert.
  4. A subclass cannot fix the driver: its methods are final and the per-group holders are private (GroupReducer.java:42, :71-98). A user can only work around it in the collector that the protected newCollector() returns (see the workaround under Suggested fix).

The numbers

5 documents, groups a, b, a | b, a, in two segments. A collector that counts a segment's hits and publishes them in finish() publishes 5 when IndexSearcher runs it directly, and 0 for group a (expected 3) and 0 for group b (expected 2) through the second pass. An independent probe saw collect() called 3 and 2 times per group and finish() 0 times (2 under IndexSearcher). With a stock FacetsCollector per group: 0 hits instead of 3 with assertions off (totalGroupedHitCount is 5), AssertionError at FacetsCollector.java:107 with assertions on, and 0 on a one-segment index even with assertions on.

Exposure and likelihood211 words
  • Who is hit. Users of the public, @lucene.experimental SecondPassGroupingCollector constructor with their own GroupReducer whose collector compiles per-segment results in finish(): a FacetsCollector per group, or any collector written against the hook. GroupReducer itself has no experimental tag, but the driver it plugs into does: a public but experimental composition point. Custom reducers are what the class was made for: in LUCENE-7701 (#8752), which created GroupReducer, romseygeek wrote "if you want to create a new type of group summarizer (say some statistics over a group), then you create a GroupReducer implementation".
  • Who is not hit. Stock two-pass grouping: the reducers of TopGroupsCollector and DistinctValuesCollector (and so GroupingSearch) use collectors that do nothing in finish().
  • Effect. Silent wrong results with assertions off, as in production: every group's finish-dependent collector reports nothing, on every search. With assertions on, a per-group FacetsCollector fails loudly on multi-segment indexes. Nothing is persisted.
  • Scale and settings. None needed: one segment is enough. Default IndexWriterConfig, codec and IndexSearcher; the opt-in part is the custom reducer.
  • How likely: rare (registry likelihood rare, priority low): a code search for extends GroupReducer on GitHub found 71 files on 2026-10-28 (70 when the fact check repeated it on 2026-10-29), all copies of Lucene's own two reducers.

Minimal repro

The test shows that a custom GroupReducer whose per-group collector is a stock FacetsCollector sees no hits, because SecondPassGroupingCollector/GroupReducer never call LeafCollector#finish on the per-group leaf collectors.

TestGroupReducerFinish.java (JUnit test)69 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.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedDocValuesField;
import org.apache.lucene.facet.FacetsCollector;
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 TestGroupReducerFinish extends LuceneTestCase {

  public void testPerGroupFacetsCollectorSeesItsHit() throws Exception {
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new SortedDocValuesField("group", new BytesRef("a")));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        SearchGroup<BytesRef> group = new SearchGroup<>();
        group.groupValue = new BytesRef("a");
        GroupReducer<BytesRef, FacetsCollector> reducer =
            new GroupReducer<>() {
              @Override
              public boolean needsScores() {
                return false;
              }

              @Override
              protected FacetsCollector newCollector() {
                return new FacetsCollector();
              }
            };
        new IndexSearcher(reader)
            .search(
                MatchAllDocsQuery.INSTANCE,
                new SecondPassGroupingCollector<>(
                    new TermGroupSelector("group"), List.of(group), reducer));
        // expected: group a's FacetsCollector reports 1 hit; actual: 0 (finish() is never called)
        assertEquals(
            1,
            reducer.getCollector(group.groupValue).getMatchingDocs().stream()
                .mapToInt(FacetsCollector.MatchingDocs::totalHits)
                .sum());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-880/, 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:$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.TestGroupReducerFinish
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: group "a"'s FacetsCollector reports 1 hit (1 doc, 1 segment, 1 group). Actual: 0 (expected:<1> but was:<0>), with -ea and -da.

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

More files, environment, full output6 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)

Why it looks unintended

  • LeafCollector#finish is the once-per-segment hook "to compile data that has been collected on this leaf"; GroupReducer accepts any Collector.
  • #12380 (9.8.0) added finish() and made MultiCollector forward it, but not this driver: a per-group FacetsCollector returning 3 and 2 on 9.7.0 returns 0 from 9.8.0.
  • DrillSideways had the same gap; upstream fixed it as a bug (#12640).
The full argument250 words
  • Contract. LeafCollector#finish is the once-per-segment lifecycle step of every leaf collector, with facet-count compilation as its named use (LeafCollector.java:149-159). GroupReducer<T, C extends Collector> accepts any collector and gives each group real leaf collectors from getLeafCollector (GroupReducer.java:86). No javadoc, package doc, CHANGES or MIGRATE entry limits per-group collectors to collect().
  • History. GroupReducer (2017) predates finish(). #12380 ("Add a post-collection hook to LeafCollector.", jpountz, 2023) added it, made IndexSearcher call it, made MultiCollector and FilterLeafCollector forward it, and moved BlockGroupingCollector, GroupFacetCollector, TermGroupFacetCollector and FacetsCollector onto it, but did not touch GroupReducer or SecondPassGroupingCollector; none of its 18 review comments mentions them. The per-group FacetsCollector works on 9.6.0 and 9.7.0 and fails from 9.8.0; 9.8.0 with only the 9.7.0 FacetsCollector.java swapped in passes.
  • Precedent. #12640 ("Ensure DrillSidewaysScorer calls LeafCollector#finish on all sideways-dim FacetsCollectors") and #12853 fixed the same symptom at another fan-out driver; on #12853 gsmiller wrote "We should consider it a bug for MatchingDocs to not be populated".
  • Lifecycle elsewhere. On dev@ ("Solr upgrade to Lucene 9.8.0 question", 2023-09-28) jpountz wrote that finish should be called "only once and when collection runs successfully" per segment; the test framework's AssertingCollector asserts that the previous leaf was finished before the next one starts.
  • Tests. No Lucene test names GroupReducer or SecondPassGroupingCollector directly. The stock second pass is tested indirectly (TopGroupsCollector and DistinctValuesCollector extend SecondPassGroupingCollector), but no test runs a finish-dependent collector through a custom GroupReducer, so none asserts the behaviour at issue.
The devil's-advocate check209 words

The strongest case (the devil's-advocate verifier; rated moderate by the reviews from another model family, OpenAI GPT-5.6): GroupReducer's javadoc promises only collect(); SecondPassGroupingCollector is @lucene.experimental; GroupReducer is a light adapter that also skips setWeight, each collector's own scoreMode(), competitiveIterator() and collect(DocIdStream); both in-tree reducers ignore finish(); and #12375's list of collectors that could use the hook did not include it.

Why it fails: the other skipped calls are separate properties of this adapter, not the mechanism here: setWeight is an optional hook, competitiveIterator() an optional skipping hint, and collect(DocIdStream) defaults to the collect(int) calls each group gets; score needs are declared through the reducer's needsScores() instead of each collector's scoreMode(), so a reducer must set it correctly, and the repro's collector and a default FacetsCollector need no scores. finish() is different: it is where collectors compile their results, and the stock FacetsCollector returns nothing without it. The in-tree reducers explain why it was missed, not that it was meant, and #12375 listed collectors that could use the hook, while GroupReducer is a driver. @lucene.experimental allows API changes, not silent empty results. The 9.7.0-pass / 9.8.0-fail bracket and the DrillSideways fixes show upstream's own view. Verdict: not intended (adjudication: confirmed, high confidence).

Suggested fix

Recommended Forward finish() to every group's current leaf collector

Forward finish(): SecondPassGroupingCollector overrides finish() and calls a new GroupReducer.finish(), which finishes each group's current leaf collector (+18 lines, 2 files). Validated: the repro passes, and the whole grouping module (57 tests) and check pass, with assertions on and off.

// GroupReducer
public final void finish() throws IOException {
  for (GroupCollector<C> collector : groups.values()) {
    if (collector.leafCollector != null) {
      collector.leafCollector.finish();
      collector.leafCollector = null;
    }
  }
}
// SecondPassGroupingCollector
@Override
public void finish() throws IOException {
  groupReducer.finish();
}
All options and trade-offs4 options · 715 words

One fix was built and validated; three alternatives were considered and not built.

Workaround for users today (not tested): a per-group collector wrapper, returned by the reducer's newCollector(), that finishes its own previous leaf collector in getLeafCollector, plus an explicit finish() on each group's last leaf collector after the search. With concurrent search each slice needs its own second-pass collector and reducer, and the last leaf collector of every group must be finished in every slice; the fix needs no such care.

Forward finish() to every group's current leaf collector (validated by the checks) (recommended)

GroupReducer.finish() calls finish() on each group's current leaf collector and clears the reference; SecondPassGroupingCollector.finish() calls it (2 files, +18 lines with a javadoc; the same code as the finder's sketch). On the normal path each per-group leaf collector then gets what IndexSearcher gives a top-level one: one finish() per segment, after collection, also when IndexSearcher caught a CollectionTerminatedException or a timeout during scoring, as MultiCollector and FilterLeafCollector already do. Exceptions while the per-group leaf collectors are set up or finished are not covered (see the trade-offs).

// GroupReducer
public final void finish() throws IOException {
  for (GroupCollector<C> collector : groups.values()) {
    if (collector.leafCollector != null) {
      collector.leafCollector.finish();
      collector.leafCollector = null;
    }
  }
}
// SecondPassGroupingCollector
@Override
public void finish() throws IOException {
  groupReducer.finish();
}

Trade-offs

  • It adds a public final method to a public abstract class: a user subclass that already declares a finish() method would stop compiling. The package-private variant below avoids that.
  • Open PR #16548 (gaobinlong, stale), the only open PR editing GroupReducer.java, catches CollectionTerminatedException per group and drops that group's leaf collector without finish(). If both land, the merged code should finish such a collector before dropping it, since the finish javadoc covers CollectionTerminatedException.
  • Exceptions need a decision. If one group's getLeafCollector throws inside setNextReader, the groups before it already hold new leaf collectors that are never finished: a CollectionTerminatedException there makes IndexSearcher skip the segment (IndexSearcher.java:836-840) and the next setNextReader overwrites them; any other exception fails the search, where no finish() is due. A nested finish() that throws stops the loop and fails the search, as in MultiCollector.finish(). The fix leaves both cases as they are. A group's CollectionTerminatedException during collect() is a separate issue (LFV-883).

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, OpenJDK 25: the repro fails on the stock tree and passes with the fix, with assertions on and off.
  • With the fix and the repro test, the whole lucene/grouping module passes with assertions on and off: 15 suites (14 upstream + the test), 57 tests, 0 failures, including TestGrouping, TestGroupingSearch, TestDistinctValuesCollector, TestTopGroups and TestBlockGrouping.
  • :lucene:grouping:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • Earlier, by the hunt: the finder's FacetsCollector test and the independent reproducer's 4 tests pass with the same code with assertions on and off; the devil's advocate's probe (2 finish() calls per group for 2 segments) and the root-cause test on 9.8.0 pass with assertions on (not run with them off on the fix).
  • Later, by the fact check: the repro with the fix with assertions on and off, and the finder's test with the fix with assertions on.
  • Not run: the full Lucene test suite, nightly tests, a benchmark (the change adds one loop over the groups per segment).

The same, with a package-private hook

void finish() (or another name) without public, called only by SecondPassGroupingCollector in the same package.

Trade-offs

No API change: a package-private method is not visible to subclasses in other packages, so an existing user finish() keeps compiling (checked with javac on two toy classes). But setNextReader, collect and setScorer are public, which suggests GroupReducer may be driven from outside the package, and such a driver could not finish the collectors. Not built.

Finish the previous leaf collector in setNextReader

Call finish() on each group's old leaf collector before replacing it.

Trade-offs

Incomplete on its own: without an end-of-segment hook the last segment is never finished, and the others are finished late, when the next segment starts. Not built.

Document the limitation

Say in GroupReducer's javadoc that per-group collectors get no finish().

Trade-offs

Cheap, but leaves a per-group FacetsCollector, which worked before 9.8.0, broken. Not built.

Downstream note

Downstream relevance only for products that compute per-group statistics, such as facet counts per group, with their own GroupReducer; no such public code was found, and stock grouping is unaffected.

How it was found

Code reading while modelling how SecondPassGroupingCollector drives each group's collector against the LeafCollector protocol (no model file was kept), then a JUnit with a stock FacetsCollector per group. An independent reproducer, a devil's advocate and an adjudication each executed their own reproduction.

The method in full145 words

Method: semi-formal translation validation (the hunt's hunt unit for a sweep of public facet, join and grouping APIs, 2026-10-28, main a9779ef3c193).

  • Lead. While modelling the second-pass fan-out of GroupReducer against the LeafCollector driver protocol (code reading; no model artifact was kept), the finder saw that the per-group leaf collectors are replaced on every segment without finish() and that SecondPassGroupingCollector forwards no finish().
  • Witness. A public-API JUnit on the stock main jars that gives each group a stock FacetsCollector failed with assertions on and off; it was run on releases back to 9.6.0 and with the fix sketch.
  • Checks. An independent reproducer wrote its own 4-test class; a devil's advocate built the strongest intended-behaviour case and its own probe; reviews by another model family (OpenAI GPT-5.6) could only read code; an adjudication re-ran the tests. This doc reduced the repro to one grouping-only method.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28): two scripted prior-report search runs over GitHub, commits, downstream repositories, four mailing lists, the JIRA archive and the web, manual GitHub searches and the changed files of all 410 open PRs. Closest: #12380, which added finish(), the DrillSideways fixes (#12640, #12853) and open PR #16548.

Searches4 searches

Related, not reports of this bug: #12375 / #12380, which introduced finish() and do not name GroupReducer or SecondPassGroupingCollector (in #12375 msokolov wrote "We've had to implement a finish() in some custom collectors for handling grouping IIRC", about custom collectors in general); #12640, #12853, #12642 and #12558, the same bug class at DrillSideways, fixed; open PR #16548 (gaobinlong), which edits GroupReducer.collect for CollectionTerminatedException and adds no finish(); #8752 / LUCENE-7701, which created GroupReducer; SOLR-5230 (fixed) and open draft Solr PR #5138 (SOLR-7520), the same class in Solr's own grouping code (different project and API). Gaps: GitHub search reads at most 50-100 results per query; list search is keyword-based; closed unmerged PRs older than two weeks were covered by text search only.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream repositories (Solr, Elasticsearch, OpenSearch)2026-10-28two scripted prior-report search runs (16 issue/PR, 3 commit and 2 downstream queries each), the finder's earlier run (45 queries) and 23 manual searches (class names, phrasings, the AssertionError text, comments); a code search for 'extends GroupReducer'no report; the code search found only copies of Lucene's own reducers
Open and recent PRs2026-10-28the changed files of all 410 open apache/lucene PRs, every issue and PR created since 2026-10-26, the module:grouping PRs since 2023-06, the upstream history of both files and CHANGES.txtonly open PR #16548 edits GroupReducer.java, and adds no finish()
Mailing lists2026-10-2820 queries per tool run over dev@, java-user@, issues@ and commits@no report; two 2023 dev@ threads on finish() semantics, not about grouping
JIRA archive and web2026-10-284 JIRA queries per tool run, 6 web queries per run and 12 more web searchesno report

History

Introduced by #12380 (2023-06-30; 8811f31b9ca6 on main, de07bdd88dd1 on 9.x), first released in 9.8.0. Executed: a per-group FacetsCollector works on 9.6.0 and 9.7.0 and fails on 9.8.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b) and main. Not fixed upstream.

Versions tested7 rows

Introduced by #12380 "Add a post-collection hook to LeafCollector." (jpountz; closes #12375; merged 2023-06-30): 8811f31b9ca6 on main (first released in 10.0.0) and the backport de07bdd88dd1 on branch_9x (first released in 9.8.0). It added LeafCollector#finish, made IndexSearcher call it, made MultiCollector and FilterLeafCollector forward it and moved FacetsCollector's MatchingDocs recording into it, but did not change GroupReducer or SecondPassGroupingCollector.

The dropping code is older: GroupReducer.setNextReader comes from LUCENE-7701 (caf329717ef6, 2017-03-28, first released in 7.0.0). It was correct while no end-of-segment hook existed: FacetsCollector then saved the previous segment in doSetNextReader and the last one in getMatchingDocs.

Affected: all 23 releases from 9.8.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b, executed) and main; the releases not executed by tag containment and identical file blobs. The bracket is release-level: between the 9.7.0 and 9.8.0 tags the only commit touching FacetsCollector.java or the grouping sources is the #12380 backport. Not fixed upstream: GroupReducer.java was last changed in 2021 (reformat) and SecondPassGroupingCollector.java in 2022 (tidy).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 and a9779ef3c193 affectedthis doc's repro fails (gradle, assertions on and off); the stock-FacetsCollector test and the root-cause test fail
branch_10_6 4ae946e5a9b, 10.5.2 affectedboth hunt tests fail; this doc's repro fails on 10.5.2
10.0.0 affectedroot-cause test: 0 finish() calls per group (2 under IndexSearcher)
9.8.0, 9.9.0, 9.10.0, 9.12.3 affectedstock FacetsCollector per group: 0 instead of 3 with assertions off; AssertionError with them on (run on 9.8.0 and 9.12.3)
9.8.0 + 9.7.0 FacetsCollector.java one-file swappasses: the flip comes from FacetsCollector moving onto finish()
9.6.0, 9.7.0 no finish() yetthe per-group FacetsCollector reports 3 and 2
main + fix not affectedthe repro passes; the grouping module (15 suites, 57 tests) passes, assertions on and off

Independent checks

The finder, an independent reproducer, a devil's advocate and an adjudication each executed their own reproduction of the mechanism (adjudication: confirmed, high); reading-only reviews by another model family agreed on the root cause; a credibility audit kept it. This doc re-ran a one-method repro on 645d4fe14a20 and validated the fix with gradle.

Checks and verdicts387 words

The cross-model reviews were reading-only: they could not run any command. The refute-lens review returned a procedural "not real, low" while agreeing on the root cause; a separate re-run of the finder's test failed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateStock-FacetsCollector JUnit fails on main (-ea, -da), branch_10_6, 10.5.2, 9.12.3, 9.10.0, 9.9.0 and 9.8.0; passes on 9.7.0 and 9.6.0 and with the fix sketch; 54 upstream grouping tests pass with the fix.
cross-model review, refute lens not real, low (procedural)OpenAI GPT-5.6, reading only; root cause agreed, inputs in contract, intended argument moderate. A separate re-run failed.
Independent reproducer realOwn 4-test JUnit: 3 of 4 fail on main, 10.5.2, 9.12.3 and 9.8.0 (finished=0 per group; one segment also gives 0); the control passes; the FacetsCollector tests pass on 9.7.0; 4 of 4 pass with the fix.
Cross-model review, intended lens real, mediumOpenAI GPT-5.6, reading only; intended argument moderate. The step re-ran the finder's test (fails).
Intended-behaviour devil's advocate real (high that it is not intended; low on reach)Re-ran the finder's test on main and 10.5.2; own probe: collect 3 and 2, finish 0 per group (2 under IndexSearcher, 2 with the fix); found open PR #16548.
adjudication confirmed, highRe-ran the independent reproduction's JUnit (-ea, -da: 3 of 4 fail) and the finder's test (fails).
Follow-up and credibility audit keepThe finder's test re-run on main with new seeds (-ea, -da) and with the fix.
this doc fails on stock, passes with the fixThe one-method repro on 645d4fe14a20 (gradle and the standalone runner, -ea, -da) and 10.5.2; the fix with the repro, the grouping module (-ea, -da) and check.
Fact checks of this doc and diagram check (2026-10-29) bug, mechanism, fix and history hold; wording correctedAn independent fact check re-ran this doc's repro (gradle and the standalone runner, assertions on and off, main and 10.5.2), the finder's test and the fix (2 citation errors, fixed). A second, reading-only fact check by another model family (OpenAI GPT-5.6) found 4 errors, 6 overclaims and 5 missing caveats, all addressed. The diagram check found no errors; the diagrams are unchanged.

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

Finding record

LFV-880, snapshot of 2026-11-06

Registry title
SecondPassGroupingCollector and GroupReducer never call LeafCollector#finish on the per-group leaf collectors, so a custom GroupReducer whose collector does its per-segment work in finish (for example a stock FacetsCollector per group) silently sees no documents
Area
grouping / GroupReducer and SecondPassGroupingCollector (second-pass per-group collectors)
Priority
Low, rank 561: It is the same finish()-not-forwarded regression as LFV-180, but it only hits users who subclass the experimental GroupReducer.
Impact class
silent wrong results
Impact note
Hits applications that write their own GroupReducer (GroupReducer<T, C extends Collector> is public and abstract, newCollector() is protected) whose per-group Collector finalizes its per-leaf results in LeafCollector#finish, and pass it to the public, @lucene.experimental SecondPassGroupingCollector constructor: with the default IndexWriterConfig, codec and searcher and assertions off, a stock FacetsCollector per group reports 0 MatchingDocs and 0 hits for every group (expected 3 and 2 in the repro, while totalGroupedHitCount is 5), with assertions on a multi-segment index fails with AssertionError ('assert docsBuilder == null' in FacetsCollector.doSetNextReader) and a single-segment index still gives 0; no scale is needed and nothing is persisted; the stock TopGroupsCollector, DistinctValuesCollector and GroupingSearch use collectors that do not depend on finish and are not affected; regression since 9.8.0, where the same per-group FacetsCollector worked on 9.7.0.
Summary
You write a GroupReducer that gives each group its own FacetsCollector (or any collector that compiles a segment's results in LeafCollector#finish), run it through SecondPassGroupingCollector, and expect each group's collector to see the documents of its group, as GroupReducer's javadoc says and as a plain IndexSearcher search would give. Instead every group's FacetsCollector reports no documents, with no error: GroupReducer.setNextReader replaces each group's leaf collector on every segment without calling finish() on the previous one, GroupReducer has no finish hook, and SecondPassGroupingCollector does not override finish(), so IndexSearcher's per-leaf finish() reaches only the outer collector. In the repro 5 documents in 2 segments are routed to groups a (3) and b (2), yet both groups report 0 hits; with assertions enabled FacetsCollector throws AssertionError on the second segment instead.
Introduced
8811f31b9ca6 (main, first released in 10.0.0) and de07bdd88dd1 (branch_9x backport, released in 9.8.0), the same 17-file patch: #12380 'Add a post-collection hook to LeafCollector.' by jpountz, merged 2023-06-30 (closes #12375); it added LeafCollector#finish, made IndexSearcher call it, made MultiCollector and FilterLeafCollector forward it, moved FacetsCollector's MatchingDocs recording into finish() (doSetNextReader now asserts docsBuilder == null) and moved three grouping collectors to finish(), but did not change GroupReducer or SecondPassGroupingCollector [read: git log -S/-G, git show, gh pr view 12380; exec: release-level bracket only, the stock FacetsCollector repro passes on 9.7.0 and fails on 9.8.0, and 9.8.0 with only the 9.7.0 FacetsCollector.java swapped in passes; no commit-level build]; the defect site itself (GroupReducer.setNextReader and the GroupCollector holder) is caf329717ef6 (LUCENE-7701 'Refactor grouping collectors', 2017-03-28, first released in 7.0.0), which was correct until #12380 defined finish [read: git blame] (2023-06-30 (#12380: merged 2023-06-30T13:19:35Z; author date 2023-06-30 15:19:35 +0200 on both commits); 2017-03-28 (caf329717ef6, origin of the GroupReducer lines, not a defect before finish existed))
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 does not exist there (the root-cause test does not compile) and FacetsCollector recorded the previous leaf in doSetNextReader and the last one in getMatchingDocs, which works through GroupReducer (the stock FacetsCollector repro passes on 9.7.0 and 9.6.0)
Affected versions
every release from 9.8.0 to 10.5.2 (23 releases: 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.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2), the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193. [exec] the stock FacetsCollector JUnit fails on 9.8.0 (-ea and -da), 9.9.0, 9.10.0, 9.12.3, 10.5.2 (-ea and -da), branch_10_6 and main (-ea and -da); the root-cause test (a per-group collector that counts finish calls: finishes=0 for both groups versus 2 under a direct IndexSearcher search) fails on 9.8.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main (-ea and -da); the independent reproduction's own JUnit fails 3 of 4 on main (-ea and -da), 10.5.2 and 9.12.3 (-ea and -da) and 9.8.0 (-da); the intended-behaviour check re-ran the finder's JUnit on main (-ea and -da) and 10.5.2 (-da); all runs on JDK 25, aarch64. [read] the other releases by git tag --contains de07bdd88dd / 8811f31b9ca and blob identity (GroupReducer.java 313172ac and SecondPassGroupingCollector.java 2000493f in every release 9.5.0-10.5.2, branch_10_6 and main; FacetsCollector#finish in every release from 9.8.0). Not affected: 9.0.0-9.7.0 and 7.0.0-8.x (no finish hook; 9.7.0 and 9.6.0 [exec] pass the FacetsCollector repro). Not fixed upstream: GroupReducer.java was last changed on upstream main by 2cbf261032dc (2021-01-05, reformat) and SecondPassGroupingCollector.java by 54fba99cb131 (2022-09-24, tidy); upstream main c5ab3e57b9ba (author date 2026-10-28) has nothing later (checked 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (2026-10-28, follow-up prior-report record verdict new: two scripted prior-report searches (each 16 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 3660 and 298 distinct hits read, 1 high, #12375, which is the origin of finish and not a report), the finder's earlier run (45 queries), manual gh issue, PR and comment searches for GroupReducer, SecondPassGroupingCollector and finish wording, all 22 issues and PRs created since 2026-10-26, the changed files of all 410 open apache/lucene PRs, closed PRs updated since 2026-10-14, the module:grouping PRs since 2023-06, the upstream commit history of both files, CHANGES.txt, downstream searches in apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, GitHub code search for 'extends GroupReducer' (71 files, all copies of Lucene's own reducers), the JIRA archive and 12 web queries); refreshed 2026-10-28 for this row: GitHub PR search for GroupReducer returns nothing, issue search returns only #8752 (2017), none of the 15 PRs and 7 issues created since 2026-10-26 is about grouping (the one merged code PR, #16919, touches no grouping or facet file), and upstream main c5ab3e57b9ba has no change to either file; not fixed upstream; related, not reports of this bug: #12380 / #12375 (introduced finish; neither the issue nor the 18 review comments mention GroupReducer or SecondPassGroupingCollector), open PR #16548 (gaobinlong, 2026-08-24, stale since 2026-09-13; the only open PR that edits GroupReducer.java: it catches CollectionTerminatedException per group in collect() and nulls that group's leaf collector, adds no finish forwarding, and a fix for this bug must be reconciled with it), #12640 / #12853 / #12642 / #12558 (the same bug class, a fan-out driver that skips or doubles finish, fixed in DrillSideways), #8752 / LUCENE-7701 (created GroupReducer in 2017; the author describes custom reducers as the extension point), #16292 (grouped faceting rewrite, does not touch these files), the 2023 dev@ threads 'LeafCollector#finish idempotency?' and 'Solr upgrade to Lucene 9.8.0 question' (finish once per leaf after successful collection), Solr SOLR-5230 and open apache/solr PR #5138 (the same class in Solr's own grouping code, different project and API); gaps: GitHub search reads at most 50-100 results per query, mailing-list search is keyword-based, closed-unmerged PRs older than two weeks were covered only by text search, and private security reports cannot be searched
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28; runs of 2026-10-29) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars. The hunt's earlier runs used main a9779ef3c193 (the grouping module and every other file involved are identical), branch_10_6 4ae946e5a9b, 10.5.2, 10.0.0, 9.12.3, 9.10.0, 9.9.0, 9.8.0, 9.7.0 and 9.6.0. The recommended fix was validated in the scratch worktree: the repro test, the whole lucene/grouping module with assertions on and off, and :lucene:grouping:check -x test.

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupReducer.java:33-34 (class javadoc: 'Each group is assigned a Collector returned by newCollector(), and LeafCollector#collect(int) is called for each document that is in a group'), :40 (public abstract class GroupReducer<T, C extends Collector>), :71-74 (collect), :84-88 (setNextReader: collector.leafCollector = collector.collector.getLeafCollector(ctx), no finish on the previous one; no finish method in the class)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:33 (@lucene.experimental), :35 (extends SimpleCollector), :51-55 (public constructor taking any GroupReducer), :98-102 (doSetNextReader calls groupReducer.setNextReader; no finish() override in the class)
  • lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:149-159 (finish: 'Hook that gets called once the leaf that is associated with this collector has finished collecting successfully, including when a CollectionTerminatedException is thrown ... typically useful to compile data that has been collected on this leaf, e.g. to convert facet counts')
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:106-115 (doSetNextReader: assert docsBuilder == null at :107), :117-128 (finish: the only place that adds MatchingDocs, at :125)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:860-862 (leafCollector.finish() per leaf on the outer collector); MultiCollector.java:221, :241, :252-256 and FilterLeafCollector.java:52-53 (core delegating collectors forward finish)
The finding in 30 seconds
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupReducer.java:33-40, :42, :59, :71, :77, :84-88, :90-98
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:33, :35, :87-102
  • lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:149-159; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:836, :860-862
  • lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:106-128
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:90-95 (early return for non-top groups, the call at :95); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:836-862 (skipped segment at :837-840; finish at :862)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:149-159; lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupReducer.java:33-40, :86
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:62, :83-85
  • grep for GroupReducer and SecondPassGroupingCollector over lucene (no reference outside lucene/grouping/src/java)
  • lucene/core/src/java/org/apache/lucene/search/Collector.java:58-63 (setWeight); lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:130-147 (collect(DocIdStream) default, competitiveIterator); lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:76-79 (scoreMode from needsScores); lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:96-97 (scoreMode); lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:46, DistinctValuesCollector.java:35 (extend SecondPassGroupingCollector)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:853-862; lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:156-157
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:836-840, :860-862; lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:252-256; lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupReducer.java:59, :84-98 (exception cases and workaround)

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; its values come from the code at 645d4fe14a20 and the executed repro.

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.