The grouping second pass gives each group its own collector, but never tells those collectors that a segment is finished.
- Two-pass grouping:
FirstPassGroupingCollectorfinds the top groups;SecondPassGroupingCollectorruns the query again and hands each matching document of those groups to that group's own collector, which aGroupReducercreates. - A collector gets one leaf collector per segment.
IndexSearchercalls itscollect()for each match, thenfinish()once when the segment is done; the javadoc names compiling per-segment data, such as facet counts, as the typical use. - Since 9.8.0,
FacetsCollectorturns a segment's matches into aMatchingDocsentry only infinish(); without it,getMatchingDocs()stays empty.
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.- 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
GroupReducerthat gives each group its own collector, for example aFacetsCollectorto count facets inside each group. - Since 9.8.0 Lucene tells a collector that a segment is done by calling
LeafCollector#finish(), andFacetsCollectorsaves a segment's matches only there. Run directly byIndexSearcher, such a collector sees all 5 documents. You expect group a's collector to report 3 and group b's 2. - Instead both report 0, with no error. The documents do reach the groups (
collect()runs 3 and 2 times), butfinish()runs 0 times:GroupReducerreplaces each group's leaf collector at the next segment without finishing it, andSecondPassGroupingCollectordoes not passIndexSearcher'sfinish()on. - With assertions on, a per-group
FacetsCollectorthrowsAssertionErroron the second segment instead. The same code returned 3 and 2 on 9.7.0.
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).
- Second pass:
SecondPassGroupingCollector(@lucene.experimental) extendsSimpleCollector, soIndexSearcheruses the collector itself as the leaf collector.doSetNextReadercallsgroupReducer.setNextReader(ctx), andcollectroutes each document of a top group togroupReducer.collect(value, doc)and returns early for the others (SecondPassGroupingCollector.java:87-102on main645d4fe14a20, 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 bynewCollector(), andLeafCollector#collect(int)is called for each document that is in a group" (GroupReducer.java:33-40).newCollector()is protected;setNextReader,collectandsetScorerare public final. The class has no@lucene.experimentaltag; 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.searchLeafcalls it after collecting each segment, also when collection ended with aCollectionTerminatedExceptionor a timeout (IndexSearcher.java:849-862; a segment whosegetLeafCollectorthrowsCollectionTerminatedExceptionis skipped, with no leaf collector to finish,:836-840);MultiCollectorandFilterLeafCollectorforward it.
GroupReducer.setNextReaderrunscollector.leafCollector = collector.collector.getLeafCollector(ctx)for every group (GroupReducer.java:84-88): the previous segment's leaf collector is overwritten withoutfinish().GroupReducerhas no finish method, andSecondPassGroupingCollectordoes not overridefinish(), so the call fromIndexSearcherruns the empty default and reaches no per-group leaf collector, neither between segments nor after the last one.FacetsCollector.doSetNextReaderassertsdocsBuilder == nulland starts a new builder (FacetsCollector.java:106-114); onlyfinish()adds the segment'sMatchingDocs(:116-128). ThroughGroupReducer, with assertions off every group'sFacetsCollectorhas noMatchingDocs; with assertions on the second segment trips the assert.- 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 protectednewCollector()returns (see the workaround under Suggested fix).
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.experimentalSecondPassGroupingCollectorconstructor with their ownGroupReducerwhose collector compiles per-segment results infinish(): aFacetsCollectorper group, or any collector written against the hook.GroupReduceritself 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 createdGroupReducer, 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
TopGroupsCollectorandDistinctValuesCollector(and soGroupingSearch) use collectors that do nothing infinish(). - 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
FacetsCollectorfails loudly on multi-segment indexes. Nothing is persisted. - Scale and settings. None needed: one segment is enough. Default
IndexWriterConfig, codec andIndexSearcher; the opt-in part is the custom reducer. - How likely: rare (registry likelihood rare, priority low): a code search for
extends GroupReduceron 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.
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.
/*
* 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());
}
}
}
}
# 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.
LeafCollector#finishis the once-per-segment hook "to compile data that has been collected on this leaf";GroupReduceraccepts anyCollector.- #12380 (9.8.0) added
finish()and madeMultiCollectorforward it, but not this driver: a per-groupFacetsCollectorreturning 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#finishis 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 fromgetLeafCollector(GroupReducer.java:86). No javadoc, package doc, CHANGES or MIGRATE entry limits per-group collectors tocollect(). - History.
GroupReducer(2017) predatesfinish(). #12380 ("Add a post-collection hook to LeafCollector.", jpountz, 2023) added it, madeIndexSearchercall it, madeMultiCollectorandFilterLeafCollectorforward it, and movedBlockGroupingCollector,GroupFacetCollector,TermGroupFacetCollectorandFacetsCollectoronto it, but did not touchGroupReducerorSecondPassGroupingCollector; none of its 18 review comments mentions them. The per-groupFacetsCollectorworks on 9.6.0 and 9.7.0 and fails from 9.8.0; 9.8.0 with only the 9.7.0FacetsCollector.javaswapped 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
finishshould be called "only once and when collection runs successfully" per segment; the test framework'sAssertingCollectorasserts that the previous leaf was finished before the next one starts. - Tests. No Lucene test names
GroupReducerorSecondPassGroupingCollectordirectly. The stock second pass is tested indirectly (TopGroupsCollectorandDistinctValuesCollectorextendSecondPassGroupingCollector), but no test runs a finish-dependent collector through a customGroupReducer, 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).
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.
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 finalmethod to a public abstract class: a user subclass that already declares afinish()method would stop compiling. The package-private variant below avoids that. - Open PR #16548 (gaobinlong, stale), the only open PR editing
GroupReducer.java, catchesCollectionTerminatedExceptionper group and drops that group's leaf collector withoutfinish(). If both land, the merged code should finish such a collector before dropping it, since thefinishjavadoc coversCollectionTerminatedException. - Exceptions need a decision. If one group's
getLeafCollectorthrows insidesetNextReader, the groups before it already hold new leaf collectors that are never finished: aCollectionTerminatedExceptionthere makesIndexSearcherskip the segment (IndexSearcher.java:836-840) and the nextsetNextReaderoverwrites them; any other exception fails the search, where nofinish()is due. A nestedfinish()that throws stops the loop and fails the search, as inMultiCollector.finish(). The fix leaves both cases as they are. A group'sCollectionTerminatedExceptionduringcollect()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/groupingmodule passes with assertions on and off: 15 suites (14 upstream + the test), 57 tests, 0 failures, includingTestGrouping,TestGroupingSearch,TestDistinctValuesCollector,TestTopGroupsandTestBlockGrouping. :lucene:grouping:check -x test(checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.- Earlier, by the hunt: the finder's
FacetsCollectortest and the independent reproducer's 4 tests pass with the same code with assertions on and off; the devil's advocate's probe (2finish()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).
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.
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.
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 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.
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
GroupReduceragainst theLeafCollectordriver protocol (code reading; no model artifact was kept), the finder saw that the per-group leaf collectors are replaced on every segment withoutfinish()and thatSecondPassGroupingCollectorforwards nofinish(). - Witness. A public-API JUnit on the stock main jars that gives each group a stock
FacetsCollectorfailed 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits and downstream repositories (Solr, Elasticsearch, OpenSearch) | 2026-10-28 | two 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 PRs | 2026-10-28 | the 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.txt | only open PR #16548 edits GroupReducer.java, and adds no finish() |
| Mailing lists | 2026-10-28 | 20 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 web | 2026-10-28 | 4 JIRA queries per tool run, 6 web queries per run and 12 more web searches | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 and a9779ef3c193 | affected | this 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 | affected | both hunt tests fail; this doc's repro fails on 10.5.2 |
| 10.0.0 | affected | root-cause test: 0 finish() calls per group (2 under IndexSearcher) |
| 9.8.0, 9.9.0, 9.10.0, 9.12.3 | affected | stock 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 swap | passes: the flip comes from FacetsCollector moving onto finish() |
| 9.6.0, 9.7.0 | no finish() yet | the per-group FacetsCollector reports 3 and 2 |
| main + fix | not affected | the repro passes; the grouping module (15 suites, 57 tests) passes, assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Stock-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 | real | Own 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, medium | OpenAI 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, high | Re-ran the independent reproduction's JUnit (-ea, -da: 3 of 4 fail) and the finder's test (fails). |
| Follow-up and credibility audit | keep | The finder's test re-run on main with new seeds (-ea, -da) and with the fix. |
| this doc | fails on stock, passes with the fix | The 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 corrected | An 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. |
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.
- PNG 512 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,622 words
- 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 maina9779ef3c193(the grouping module and every other file involved are identical),branch_10_64ae946e5a9b, 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 wholelucene/groupingmodule with assertions on and off, and:lucene:grouping:check -x test.
- 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-98lucene/grouping/src/java/org/apache/lucene/search/grouping/SecondPassGroupingCollector.java:33, :35, :87-102lucene/core/src/java/org/apache/lucene/search/LeafCollector.java:149-159; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:836, :860-862lucene/facet/src/java/org/apache/lucene/facet/FacetsCollector.java:106-128lucene/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, :86lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingCollector.java:62, :83-85grep 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-157lucene/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)
- TestGroupReducerFinish.java (JUnit test):
repro/LFV-880/src/org/apache/lucene/search/grouping/TestGroupReducerFinish.java(69 lines)
It is made in parallel with this doc; its values come from the code at 645d4fe14a20 and the executed repro.
assets/LFV-880/bug.png(512 KB)
