MultiCollectorManager must give each child manager back exactly the collectors that child created. With exactly one child whose collector is itself a MultiCollector, it unwraps that collector as if it were its own wrapper and hands the child the wrong ones.
- A
CollectorManagermakes oneCollectorper search slice (newCollector()) and, after the search, turns those collectors into a result (reduce(collectors)).TopScoreDocCollectorManager(k, ...)returns the top-k hits. MultiCollectorManager(m1, m2, ...)combines managers: its collector feeds every child's collector, and its result is anObject[]with one entry per child.MultiCollector.wrap(c1, c2, ...)combines collectors into aMultiCollector; given a single collector, it returns that collector as is, without a wrapper.
inner = MCM(top-1, top-2). During the search the outer collector is inner's own MultiCollector{a, b}. At reduce time the outer manager unwraps it and passes only a: top-1 takes a's hit, and top-2 reads the same, now empty, collector and returns 0 hits. Select the still to open it at full size. Generated with AI, checked against the code.- You combine two top-k managers,
inner = new MultiCollectorManager(top-1, top-2). Searched directly on 2 matching documents, it returns 1 and 2 hits. - Your code builds its manager list dynamically and this time wraps only that one manager:
searcher.search(q, new MultiCollectorManager(inner)). - You expect the same 1 and 2 hits:
MultiCollectorManageris a composite "akin to how MultiCollector wraps Collector instances" and accepts any non-null managers. - You get 0 hits for top-2, with no error. With one child,
newCollector()returnsinner's ownMultiCollectorunwrapped, andreducemistakes it for its own wrapper: it handsinneronly the top-1 collector, so top-2 reads that collector after top-1 has already emptied it. - With a top-5 child next to a hit-count child, the count's
reducethrowsClassCastExceptioninstead. Two outer children work, and 8.11.4 works; 9.0.0 to 10.6.0 and main fail.
Applications that wrap exactly one manager in a MultiCollectorManager when that manager is itself composite (a nested MultiCollectorManager, or a custom manager returning MultiCollector.wrap(...)), typically code that adds facet or count managers only on request. Default settings, every search. No in-tree non-sandbox caller does this; the sandbox FacetOrchestrator with no facet builders does. Rare.
Mechanism, terms and numbers716 words
MultiCollectorManager runs several collector managers in one search and returns one result per manager. Since 9.0.0 its newCollector() returns MultiCollector.wrap(collectors), and wrap returns a single collector unchanged. So when the manager has exactly one child whose collector is itself a MultiCollector (a nested MultiCollectorManager with two or more children, or a custom manager that returns MultiCollector.wrap(...)), the outer manager's collector is the child's own MultiCollector. reduce cannot tell the two apart: it unwraps every MultiCollector it receives and hands the child only the first sub-collector. The inner manager then reduces its first child correctly and every later child over the first child's collector: on 2 matching documents, new MultiCollectorManager(new MultiCollectorManager(top-1, top-2)) returns 0 hits for top-2 instead of 2, and children of different types throw ClassCastException inside a well-typed reduce. The mix-up happens on every search, with default settings, assertions on and off, in every slicing. Two outer children work, and 8.x works: LUCENE-10000 (#196) introduced it in 9.0.0, and 9.0.0 to 10.6.0 and main are affected. Reach is narrow (no in-tree non-sandbox caller builds the shape); the fix is to pass the collectors through unchanged when there is one child.
newCollector()wraps withMultiCollector.wrap(MultiCollectorManager.java:49-56): one collector per child, returned asMultiCollector.wrap(collectors)(:55).wrapof one collector returns it unwrapped, as documented: "If the input contains 1 real collector (i.e. non-null), it is returned" (MultiCollector.java:47; then == 1branch at:68-77). The constructor is private (:93), so everyMultiCollectorhas two or more sub-collectors.- So with one child the outer collector is the child's own. For
outer = MCM(inner),inner = MCM(A, B):outer.newCollector()iswrap([inner.newCollector()]), which isinner'sMultiCollector{a, b}. Collection is right:aandbboth see every hit. reduceunwraps anyMultiCollector(MultiCollectorManager.java:58-76): for childi,if (collector instanceof MultiCollector mc) reducibleCollector.add(mc.getCollectors()[i]); else reducibleCollector.add(collector);(:64-72). Its comment gives the reason: "MultiCollector will not actually wrap the collector if only one is provided, so we check the instance type here" (:65-66). That is the manager's own single-collector shortcut; the type check cannot tell it apart from a child's ownMultiCollector. With one child,outertakesinner'sMultiCollectorfor its own and passesinner.reducethe list[a].inner.reduce([a])runs the same loop:ais not aMultiCollector, so child 0 gets[a](right) and child 1 also gets[a](wrong);bis never reduced.- Why top-2 is empty.
TopScoreDocCollectorManager.reducecallstopDocs()on each collector and merges (TopScoreDocCollectorManager.java:103-110).TopScoreDocCollectorkeeps its hits in a heap (TopScoreDocCollector.java:36,:44), andtopDocs()(TopDocsCollector.java:94-99) pops that heap (populateResults,TopScoreDocCollector.java:188-193). After the top-1 manager poppeda's only entry,topDocsSize()(:177-185) is 0, sotopDocsreturnsnewTopDocs(null, 0)(TopDocsCollector.java:148-149): an empty hit list that still reportstotalHits= 2 (TopScoreDocCollector.java:51-55). From 9.0.0 to 10.2.x the collector kept its hits in aHitQueueinstead; the repro fails the same way on 9.10.0, 9.12.3 and 10.0.0. - Why a mixed pair throws. A child typed
CollectorManager<TotalHitCountCollector, Integer>receives aTopScoreDocCollectorthrough the raw(CollectorManager[])cast (MultiCollectorManager.java:46), and its ownreducethrowsClassCastException. - Why two outer children work.
wrapthen builds a realMultiCollectorwith one slot per outer child, sogetCollectors()[i]is childi's collector even when that collector is itself aMultiCollector.
- A custom manager whose
newCollector()returnsMultiCollector.wrap(c1, c2)and whosereducecasts back toMultiCollector, the pattern LUCENE-10244 (9.1.0) made public and that Lucene's ownTestCollectorManager.CompositeCollectorManageruses (TestCollectorManager.java:127-147), wrapped alone:ClassCastExceptionon main, 9.1.0 and 10.5.2. - The sandbox
FacetOrchestratorwith no facet builders wraps the caller's main manager alone (FacetOrchestrator.java:66-82); withMCM(top-5, hit count)as the main manager it throwsClassCastExceptionon main. MCM(MCM(top-5, TotalHitCountCollectorManager))on 10 matching documents:TopScoreDocCollector cannot be cast to class org.apache.lucene.search.TotalHitCountCollector.
It is in MultiCollectorManager itself: an independent reproducer saw it calling newCollector()/reduce() directly, without IndexSearcher, with one and with two collectors, and the finder's harness saw it in every slicing of every index of 1-3 segments x 1-6 documents, sequentially and with a thread pool.
Exposure and likelihood208 words
- What goes wrong. For an affected composition every search hands the inner manager's second and later children the first child's collectors. What they return then depends on the children: with top-k children, an empty top-k list with no error whenever the query has hits; a value computed from the first child's collectors, which can happen to be right (two hit-count children return the right count, and a query with no hits returns the right empty lists, executed); or a
ClassCastExceptionfor that request when the children's collector types differ. Nothing is persisted. - Configuration. Default settings; assertions on and off; any slicing or executor. No index content or scale is needed: the repro has 2 empty documents.
- In-tree callers.
GroupingSearchcan build a one-childMultiCollectorManager(GroupingSearch.java:149-165), but its child produces plain collectors; the facet module andDrillSidewaysalways pass two managers. The sandboxFacetOrchestratorbuilds the shape when there are no facet builders. - Downstream. None of the downstream projects read for this (Solr, Elasticsearch, OpenSearch and four others; code reading only) builds the shape; Solr keeps its own copy of the pre-9.0 design.
- Likelihood. Rare: no report was found since 9.0.0 (tagged 2021-12-06). The workaround is trivial: do not wrap a single manager, or flatten the tree.
One JUnit class, one method, public API only: 2 empty documents, the default IndexWriterConfig, a plain IndexSearcher, MatchAllDocsQuery, and new MultiCollectorManager(new MultiCollectorManager(top-1, top-2)). It asserts that the top-2 list has 2 hits. Fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMultiCollectorManagerSingleNested extends LuceneTestCase {
public void testSingleNestedManager() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
w.addDocument(new Document());
try (DirectoryReader reader = DirectoryReader.open(w)) {
MultiCollectorManager inner =
new MultiCollectorManager(
new TopScoreDocCollectorManager(1, Integer.MAX_VALUE),
new TopScoreDocCollectorManager(2, Integer.MAX_VALUE));
Object[] results =
(Object[])
new IndexSearcher(reader)
.search(new MatchAllDocsQuery(), new MultiCollectorManager(inner))[0];
// expected: the inner top-2 manager returns 2 hits, as when inner is searched directly
assertEquals(2, ((TopDocs) results[1]).scoreDocs.length);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestMultiCollectorManagerSingleNested -Ptests.seed=DEADBEEF
- Expected
- The inner top-2 manager returns 2 hits, as when the inner manager is searched directly.
- Observed
expected:<2> but was:<0>: the top-2 manager was reduced over the top-1 manager's collector, which the top-1 manager had already emptied; the emptyscoreDocscomes withtotalHitsstill 2.
More files, environment, full output305 words
The class sits in org.apache.lucene.search, next to TestMultiCollectorManager, and uses nothing package-private. ByteBuffersDirectory and an IndexSearcher without an executor: one segment, one slice, sequential. It also fails 3 of 3 with a second seed. The test body uses no random input, but LuceneTestCase still randomizes the default codec, locale and time zone per seed; the diagram probe, a plain main() outside the test framework, shows the same 0 hits.
It compiles on 9.10.0 and later (the first release with TopScoreDocCollectorManager). For 9.0.0 to 9.9.x the follow-up's version-neutral test covers the same shape.
Regression test: this same test (as its own class or as one method in TestMultiCollectorManager). With the fix it passes, together with the core format, ECJ and forbidden-apis checks. For the upstream test on main, use MatchAllDocsQuery.INSTANCE: the new MatchAllDocsQuery() constructor is deprecated there (MatchAllDocsQuery.java:26-34; INSTANCE exists since 10.4.0). The INSTANCE variant fails the same way on stock main and passes with the fix (executed). The standalone repro keeps the constructor so that it also compiles on 9.10.0 to 10.3.x.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain Java logic) |
cp $REPRO/TestMultiCollectorManagerSingleNested.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestMultiCollectorManagerSingleNested -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestMultiCollectorManagerSingleNested > testSingleNestedManager FAILED
java.lang.AssertionError: expected:<2> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:B9D11D6158E04470]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.search.TestMultiCollectorManagerSingleNested.testSingleNestedManager(TestMultiCollectorManagerSingleNested.java:44)
# standalone runner, seed DEADBEEF: 1 of 1 failed, same message, on
main 645d4fe14a20 (-ea and -da), 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0
# with the recommended fix: the test passes (assertions on and off)- Documented as a composite "akin to how MultiCollector wraps Collector instances" and tested with a single child;
MCM(MCM(A, B), C)works, onlyMCM(MCM(A, B))breaks. - The code's own comment explains the type check only by the manager's own single-collector shortcut.
- A regression: 8.11.4 passes, 9.0.0 fails; #196 aimed at early-termination parity, not at limiting composition.
The full argument310 words
- The contract.
CollectorManager:newCollector()"must return a NEW collector which will be used to collect a certain set of leaves", andreduce"will be used to reduce the results of individual collections into a meaningful result" (CollectorManager.java:28-32);reduceis typed by the manager's own collector type (T reduce(Collection<C> collectors),:55). Here a child receives another child's collector, and a well-typed child throwsClassCastException. - The class promises composition. "A composite CollectorManager which wraps a set of CollectorManager instances, akin to how MultiCollector wraps Collector instances" (
MultiCollectorManager.java:24-27), andMultiCollectoritself nests fine. The constructor accepts any non-null managers, at least one (:34-47).TestMultiCollectorManager.testCollectiontests "only wrapping one of the collector managers" (TestMultiCollectorManager.java:58-63), with a plain child. - The comment at
MultiCollectorManager.java:65-66explains the type check by the manager's own single-collector shortcut. It does not mention a child that returns its ownMultiCollector, and the check cannot tell the two apart. - Inconsistent by arity.
MCM(MCM(A, B), C)works andMCM(MCM(A, B))does not (executed on main, 9.0.0 and 10.5.2). - Regression. From 6.4.0 to 8.11.4,
newCollector()returned an object of the manager's ownCollectorsclass (a public inner class with a private constructor) andreduceindexedcollectors.collectors[i]with no type test, so nesting was unambiguous. #196's CHANGES entry is aboutCollectionTerminatedExceptionandsetMinCompetitiveScoreparity (CHANGES.txt:4923-4924, 9.0.0), and its review never discusses nesting. - The second trigger uses public API added later. LUCENE-10244 (9.1.0) made
MultiCollector#getCollectorspublic, "allowing users to access the wrapped collectors" (CHANGES.txt:4344-4345; package-private in 9.0.0). Since then a manager can returnMultiCollector.wrap(...)and unwrap it in its ownreduce, as Lucene'sTestCollectorManager.CompositeCollectorManagerdoes. That makes the pattern public API; it does not mean every way of nesting it was considered. - Nothing documents a restriction. No javadoc, test, CHANGES or MIGRATE entry declares the shape unsupported.
The devil's-advocate check193 words
The strongest case for "intended" (a works-as-designed cross-model review and a devil's-advocate verifier): CollectorManager is @lucene.experimental (CollectorManager.java:42); wrap's single-collector shortcut is documented and the reduce comment shows the author knew it; nesting was never promised; and wrapping a single manager is redundant, so a maintainer could call it an unsupported, degenerate composition with a trivial workaround.
Why it fails: experimental status allows API changes, not silently wrong results; the restriction is written nowhere; one-child MultiCollectorManagers arise from dynamic construction (GroupingSearch, whose child cannot trigger the bug, and the sandbox FacetOrchestrator, whose caller-supplied main manager can); the nested-manager composition worked in 8.x (the custom-manager route needs getCollectors, public only since 9.1.0); and a two-child outer manager with the same nested child works today. The refute review, the independent reproducer, the devil's-advocate verifier and the adjudication rated the case weak; the works-as-designed review still called the bug real (medium). The cross-model fact check of this doc (OpenAI GPT-5.6) rated the case moderate: "won't fix" or "document the restriction" would be a plausible maintainer answer for a real but narrow, easily avoided regression. Verdict: not intended, a regression.
Recommended Pass the collectors through unchanged when there is one child (recommended, validated)
In reduce, when there is exactly one child, pass the collectors through unchanged (+6 lines in MultiCollectorManager.java). That mirrors newCollector(), which returns the single child's collector unwrapped. Validated: the repro passes; 2,340 core search tests, the grouping, facet, join, sandbox-facet and demo modules and the core lint checks pass.
final Object[] results = new Object[collectorManagers.length];
if (collectorManagers.length == 1) {
// newCollector() returned the single child's own collector (MultiCollector.wrap does not wrap
// a single collector), even if it is itself a MultiCollector: pass it through unchanged
results[0] = collectorManagers[0].reduce(reducibleCollectors);
return results;
}All options and trade-offs2 options ยท 470 words
One fix was validated through gradle in a scratch worktree of 645d4fe14a20; it is the finder's fix with a reworded comment. The alternative was suggested by the reviewers and not built. Whichever is chosen, the invariant is: a child's reduce must receive exactly the collectors its own newCollector() returned.
With one child, newCollector() returns that child's collector as is, so reduce hands the same objects back instead of guessing from their runtime type. The multi-child loop is unchanged.
final Object[] results = new Object[collectorManagers.length];
if (collectorManagers.length == 1) {
// newCollector() returned the single child's own collector (MultiCollector.wrap does not wrap
// a single collector), even if it is itself a MultiCollector: pass it through unchanged
results[0] = collectorManagers[0].reduce(reducibleCollectors);
return results;
}Trade-offs
- Smallest change, no hot-path cost:
reduceruns once per search, after collection. - The loop's comment ("MultiCollector will not actually wrap the collector if only one is provided ...",
:65-66) then describes a case the loop no longer sees for one child; a committer may want to reword it. - Not covered and not claimed: a child manager whose
newCollector()returnsnullwould shift indices in the multi-child path becausewrapdrops nulls, butCollectorManagerdoes not allow null collectors.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: it passes with assertions on and off. - The repro plus the 8 closest suites (
TestMultiCollectorManager,TestMultiCollector,TestCollectorManager,TestIndexSearcher,TestCachingCollector,TestCachingCollectorManager,TestTopDocsCollector,TestTotalHitCountCollector): 57 tests, 1 skipped, 0 failures. :lucene:core:testoverorg.apache.lucene.search.*: 2,340 tests, 49 skipped, 0 failures;:lucene:grouping:test(56),:lucene:facet:test(306),:lucene:join:test(104),:lucene:sandbox:testoverorg.apache.lucene.sandbox.facet.*(61),:lucene:demo:test(23): 0 failures.checkGoogleJavaFormat,ecjLintMain,ecjLintTest(with the repro test in place),forbiddenApisMain,forbiddenApisTest: pass.- Earlier, with the finder's version: the finder's 3-test repro (3 of 3), the independent reproducer's 7-test JUnit (7 of 7), the cross-model reviewer's own test (2 of 2) and upstream
TestMultiCollectorManagerpassed.
Return the manager's own wrapper even for one child (as before 9.0, or through a package-private MultiCollector factory that always wraps), so reduce never has to guess from the runtime type.
Trade-offs
For one child it adds a delegation layer on every collected hit that wrap currently avoids. The factory variant touches MultiCollector (its constructor is private, MultiCollector.java:93). A manager-owned wrapper, as before 9.0, stays in MultiCollectorManager.java, but it must re-implement the CollectionTerminatedException, setMinCompetitiveScore and score-caching handling that #196 got by delegating to MultiCollector, not just bring back the old type. Suggested by the reviewers as what a committer might prefer; not built or run.
Downstream code that derives its manager list from the request around a composite main manager would get empty hit lists or a ClassCastException whenever only that manager is left; none of the downstream projects read builds this shape.
While writing a bounded-exhaustive harness that runs Lucene's real collector managers on every small index and slicing, the finder read reduce's instanceof MultiCollector check, suspected it cannot tell its own wrapper from a child's, and added nested-manager specs. The harness then saw the wrong result in every slicing; the finder reduced it to a public-API JUnit.
The method in full171 words
Method: semi-formal (bounded-exhaustive translation validation, then public-API JUnit tests).
- A hunt unit on concurrent collector managers wrote a harness that runs the real collector managers on every index of 1-3 segments x 1-6 documents (258 shapes) under every distinct
IndexSearcherslicing (3,750), sequentially and with a 3-thread pool, and compares each result with the inner manager searched directly. - Reading
MultiCollectorManager.reduce'sinstanceof MultiCollectorcheck while writing it, the finder suspected the check cannot tell the manager's own wrapper from a child'sMultiCollector, and added nested specs. So the hypothesis came from code reading; the harness confirmed it:MultiCollectorManager(MultiCollectorManager(top-1, top-3))gave a wrong result in every slicing (30,000 times with assertions on and with them off; 0 with the fix), while the two-child nested spec passed. - The finder reduced it to a one-segment public-API JUnit. A cross-model review (OpenAI GPT-5.6), an independent reproducer, a works-as-designed review, a devil's-advocate verifier and an adjudication checked it; the follow-up added the history, prior-art and security checks, and the repro was minimized to one test.
No prior report found (searched 2026-10-26 over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web; GitHub rechecked 2026-10-31). Closest: LUCENE-10000 (#196), which introduced it, and LUCENE-10244 (#455), which made the second trigger public.
Searches2 searches
Verdict new: no issue, PR, commit, list thread, JIRA entry, downstream issue or web page describes a one-child MultiCollectorManager around a manager that produces MultiCollectors. Related, not reports: #11039 / #196 (LUCENE-10000, the origin; its review never discusses nesting), #11280 / #455 (LUCENE-10244, made getCollectors public), #8639 (LUCENE-7588, the original own-wrapper design), #11147 / #303 (leaf-level single-collector wrapping), #13671 (a general remark that newCollector and reduce are decoupled), #13493 / #13494 (an NPE with a two-child manager). In OpenSearch#8303 the PR's author states the same invariant in a review thread, replying to a reviewer ("the CollectorManager expects the input collectors to be the one created by the manager") about that project's own unwrapping. Upstream main 2f424fd428da (2026-10-30) still has the same reduce.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, JIRA, web | 2026-10-26 | four runs of a scripted prior-report search, 51 queries each (class and method names, the exact error messages, composition terms); direct GitHub searches on apache/lucene, OpenSearch, Elasticsearch, Solr and all of GitHub; downstream code searches; JIRA REST; 20 web queries; the full text of the origin items; the changed files of all 413 open apache/lucene PRs | no report; related #11039 / #196, #11280 / #455, #8639, #11147 / #303, #13671, #13493 / #13494, OpenSearch#8303 |
| GitHub (recheck) | 2026-10-31 | MultiCollectorManager items created since 2026-10-20; "MultiCollectorManager nested"; "MultiCollectorManager" reduce wrong; "MultiCollector.wrap" single; the file's commit history on upstream main | no new items; only #13671, #13568 and #5813 (unrelated); last commit on the file #15874 (2026-04-01, an instanceof pattern variable) |
Introduced by #196 (LUCENE-10000, ad7746d6e31, merged 2021-07-23), which switched newCollector() to MultiCollector.wrap and added the unwrap in reduce. First released in 9.0.0; 9.0.0 to 10.6.0 and main fail, 8.x passes, and swapping only this file (on 8.11.4 together with the getter #196 added to MultiCollector) flips the result both ways. Not fixed upstream.
Versions tested7 rows
Introduced by apache/lucene#196, "LUCENE-10000: Make MultiCollectorManager consistent with MultiCollector" (Greg Miller, gsmiller; approved by jpountz), merged 2021-07-23 as ad7746d6e31 (issue #11039). It replaced the manager's own Collectors wrapper (a public inner class with a private constructor) with MultiCollector.wrap in newCollector() and added the instanceof MultiCollector unwrap to reduce. The single-collector shortcut of wrap is older (2010); it became a problem only when #196 relied on it. The file's two later commits, #14100 (2025) and #15874 (2026), are cosmetic.
First affected release: 9.0.0 (tag 2021-12-06). The 8.x line never received #196: 8.10.0 to 8.11.4, released after the merge from branch_8x, keep the old design. 6.4.0 to 7.7.3 have the old design too (read, not run); 6.3.0 and earlier have no MultiCollectorManager.
Commit-level check: swapping only MultiCollectorManager.java to its pre-#196 version turns the failure into a pass on 9.0.0, 9.12.3, 10.5.2 and main; swapping the #196 version into 8.11.4 (together with 8.11.4's MultiCollector.java plus the package-private getter #196 added, so that it compiles) turns the pass into the same failure. Upstream main 2f424fd428da (2026-10-30) has the same file.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (0 hits, expected 2) |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0 | affected | minimal repro fails, same message |
9.0.0 to 10.5.2 (all 33 releases), branch_10_6, main ea57360912ed | affected | the follow-up's version-neutral test fails (also with assertions off on 9.0.0, 9.12.3, 10.5.2 and main) |
| 9.0.0 | first | first release with #196; version-neutral test fails |
| 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.4 | not affected | version-neutral test passes (8.11.4 also with assertions off) |
pre-#196 MultiCollectorManager.java on 9.0.0, 9.12.3, 10.5.2, main; #196 version on 8.11.4 | file swap | the old file passes on 9.x/10.x/main; the #196 file fails on 8.11.4 |
with the recommended fix (645d4fe14a20) | not affected | repro passes; core search tests and the grouping, facet, join, sandbox-facet and demo modules pass |
A cross-model review that ran its own test, an independent reproducer and a devil's-advocate verifier all said real; a works-as-designed review, reading only, said real (medium); the adjudication confirmed with high confidence. The follow-up pinned the regression to MultiCollectorManager.java in both directions (on 8.11.4 together with the getter #196 added to MultiCollector).
Checks and verdicts481 words
Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with identical files involved) or on release jars.
| Check | Verdict | What it found |
|---|---|---|
| Finder (harness + JUnit) | candidate | Harness: wrong result in every slicing, 0 with the fix, two-child nested spec passes; JUnit fails 2 of 3 on main with assertions on and off, 10.0.0 and 10.5.2 (empty top-5, ClassCastException); control passes; the fix passes 3 of 3. |
| Cross-model review, refute lens (OpenAI GPT-5.6, executed) | real, high | Its own test fails on main with four seeds (assertions on and off), its control passes; with the fix its test and upstream TestMultiCollectorManager pass; root cause agreed; intended case weak. |
| Cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real, medium | Could not run code; strongest not-real points: experimental status and the documented wrap shortcut; a separate re-run of the finder's test failed 2 of 3. |
| Independent reproducer | real | Own 7-test JUnit (identity-recording managers, direct newCollector()/reduce() calls, stock top-k managers, a typed child) fails 5 of 7 on main, 10.5.2, 10.0.0 and 9.12.3, controls pass; a core-only main() fails on main and 9.0.0 and passes on 8.11.4; 7 of 7 with the fix. |
| Devil's advocate | real, high | Version-neutral probe fails only MCM(MCM(A, B)) on main, 9.0.0 and 10.5.2 and passes all cases on 8.8.1 and 8.11.4; the LUCENE-10244-pattern probe and the sandbox FacetOrchestrator probe throw. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's JUnit (5 of 7 fail, assertions on and off) and the finder's (2 of 3); the independent reproduction's passes 7 of 7 with the fix. |
| Follow-up | history, prior art, security | 63 history runs (45 fail, 16 pass, 2 compile errors from API differences) and swaps of MultiCollectorManager.java both ways (on 8.11.4 with #196's MultiCollector getter); prior-art verdict new; security none, with an executed probe. |
| Repro minimization and main refresh | verified; still reproduces | The one-test repro fails on 645d4fe14a20 with assertions on and off and passes with the fix; the finder's JUnit still fails 2 of 3 on a9779ef3c193. |
| This doc | reproduced, fix validated | Gradle on 645d4fe14a20 (stock fails, fix passes, broad and lint checks pass); the standalone runner on main and 9.10.0 to 10.6.0. |
| Fact checks of this doc (2026-10-31) | bug confirmed; corrections applied | An independent fact check re-ran the repro on main (assertions on and off, and through gradle), 10.6.0 and 9.10.0, the version-neutral test on 8.11.4 (pass) and 9.0.0 (fail) and the fix, plus its own probes; a cross-model fact check (OpenAI GPT-5.6) re-read the code and the records and rated the intended-behaviour case moderate; a diagram check compared every frame with the code and the probe. None changed the bug, the repro, the affected versions or the fix. Their corrections (the heap-based topDocs path on main, the old wrapper's visibility, wording) are applied. |
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 482 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,424 words
- Registry title
- MultiCollectorManager wrapping exactly one manager whose collectors are MultiCollectors (for example a nested MultiCollectorManager) hands that manager the wrong collectors in reduce, so its second and later sub-managers return silently wrong results or throw ClassCastException
- Area
- search / MultiCollectorManager (composition of CollectorManagers)
- Priority
- Low, rank 864: A MultiCollectorManager around exactly one composite manager reduces its later children over the first child's collectors (empty top-k or ClassCastException), deterministic but a rare composition with no in-tree non-sandbox caller.
- Impact class
- silent wrong results
- Impact note
- Hits applications that wrap exactly one CollectorManager in a MultiCollectorManager when that one manager itself produces MultiCollector instances (a nested MultiCollectorManager with two or more children, a custom manager that returns MultiCollector.wrap(...) as LUCENE-10244 made possible, or the sandbox FacetOrchestrator with no facet builders around a composite main manager); for them it fires on every search, with default settings, with -ea and -da and in every slicing: the inner manager's first sub-manager is reduced correctly and every later sub-manager is reduced over the first one's collectors, so a top-k list comes back empty (0 of 5 hits in the finder's test) or a well-typed reduce throws ClassCastException; nothing is persisted, no scale is needed, no in-tree non-sandbox caller builds this shape, and the workaround is not to wrap a single manager.
- Summary
- You wrap one collector manager in a MultiCollectorManager, for example new MultiCollectorManager(new MultiCollectorManager(top1, top5)) from code that builds the manager list dynamically, and expect the same results as searching with the inner manager directly. Instead, because MultiCollector.wrap returns a single collector unwrapped, the outer manager's reduce takes the inner manager's own MultiCollector for its wrapper and unwraps it, so the inner manager's second child is reduced over the first child's collectors. In the finder's test the top-5 list comes back with 0 hits instead of 5, and with a top-k child next to a hit-count child the count manager's reduce throws ClassCastException (TopScoreDocCollector cannot be cast to TotalHitCountCollector).
- Introduced
- ad7746d6e31b (apache/lucene#196 'LUCENE-10000: Make MultiCollectorManager consistent with MultiCollector' by gsmiller, approved by jpountz; issue #11039 = LUCENE-10000, fix version 9.0) [read: git log --follow and git blame with the two later cosmetic commits ignored attribute both the MultiCollector.wrap call in newCollector and the instanceof-MultiCollector unwrap in reduce to this commit; exec: the same test passes on every executed 8.x release and fails on every release from 9.0.0, swapping only MultiCollectorManager.java to its pre-#196 version turns the failure into a pass on 9.0.0, 9.12.3, 10.5.2 and main, and swapping in the #196 version on 8.11.4 turns the pass into the same failure; no Lucene build at ad7746d6e31 or its parent was made] (2021-07-23 (merged 2021-07-23T02:02:15Z; commit timestamp 2021-07-22; PR opened 2021-06-29))
- First affected version
- 9.0.0 [exec] (tag 2021-12-06); 8.11.4 is the last unaffected release [exec]: the 8.x line never received #196, so 8.10.0-8.11.4, although released after the merge, keep the old design
- Affected versions
- every release from 9.0.0 to 10.5.2 (all 33: 9.0.0, 9.1.0-9.4.2, 9.5.0-9.12.3, 10.0.0-10.5.2), branch_10_6 (10.6.0-SNAPSHOT at d04ee731772; 10.6.0 [exec: the minimal repro fails on the 10.6.0 jars], MultiCollectorManager.java and MultiCollector.java unchanged through 4ae946e5a9b) and main ea57360912ed. [exec] the follow-up's version-neutral test (MultiCollectorManager(MultiCollectorManager(A, B)); B must reduce its own collectors) fails 1 of 1 on each of them with -ea, and also with -da on 9.0.0, 9.12.3, 10.5.2 and main; the finder's 3-test repro, which compiles only on 10.0.0+, fails 2 of 3 on 10.0.0, 10.5.2, branch_10_6 and main (-da too on main); the independent reproduction's JUnit fails 5 of 7 on main (-ea, -da), 10.5.2, 10.0.0 and 9.12.3, and its core-only main() fails on main and 9.0.0; the intended-behaviour check's probe fails on main (-ea, -da), 9.0.0 and 10.5.2, and its LUCENE-10244-pattern probe on main, 9.1.0 and 10.5.2. Not affected [exec]: 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1 and 8.11.0-8.11.4 (follow-up; 8.11.4 also -da); 8.8.1 and 8.11.4 in other checks. [read] 6.4.0-7.7.3 have the old, unaffected design (class lists of the 6.4.0, 7.0.0 and 7.7.3 jars; not executed because the test uses ScoreMode from 8.0); 6.3.0 and earlier have no MultiCollectorManager; upstream main and branch_10x still have the same reduce. Not executed: x86.
- 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: four scripted prior-report searches, 51 queries each, over GitHub issues/PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (one for the finder, three with class-name, exact-error-message and composition variants), plus direct gh searches on apache/lucene (MultiCollectorManager alone and with nested, reduce, ClassCastException; 'instanceof MultiCollector'; 'getCollectors()[i]'; 'MultiCollector.wrap' CollectorManager; 'nested collector manager'), OpenSearch, Elasticsearch, Solr and all of GitHub, code searches in OpenSearch, Elasticsearch and Solr, JIRA REST and 20 web queries, the bodies and comments of the origin items, and the file lists of all 413 open apache/lucene PRs; none reports this defect); not fixed upstream: upstream main and branch_10x still unwrap any MultiCollector in reduce (the last change, 1518164afa39 (#15874, 2026-04-01), only introduced an instanceof pattern variable), and no open PR touches MultiCollectorManager.java (#13126 and #16542 touch MultiCollector.java or its test only); related, not reports of this bug: #11039 / #196 (LUCENE-10000, the change that introduced it; its issue comments, review comments and reviews never discuss nesting or a single wrapped manager), #11280 / #455 (LUCENE-10244, made MultiCollector#getCollectors public and so enabled managers that return MultiCollector.wrap(...), the second trigger; no discussion of MultiCollectorManager's unwrap), #11147 / #303 (LUCENE-10110, leaf-level single-collector wrapping), #8639 (LUCENE-7588, introduced MultiCollectorManager with its own private wrapper), #13671 (a general remark that newCollector and reduce are decoupled), #13493 / #13494 (an NPE with a two-child manager, different fix site), OpenSearch#8303 (a reviewer states the same invariant about that project's own unwrapping); gaps: a closed, unmerged PR that changed the file without naming the class would not be found, and private security reports and chat channels cannot be searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree and through a standalone runner (assertions on and off), which also fails it on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.10.0. With the recommended fix it passes, and so do 2,340 core search tests, the grouping, facet, join, sandbox-facet and demo modules and the core format, ECJ and forbidden-apis checks. The hunt, the verifiers and the adjudication ran mainea57360912ed, a refresh re-ran the repros ona9779ef3c193; the files involved are identical in all three. The follow-up ran a version-neutral test on all 33 releases from 9.0.0 to 10.5.2 (fail) and on ten 8.x releases from 8.0.0 to 8.11.4 (pass).
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/MultiCollectorManager.java:25-26 (class javadoc 'akin to how MultiCollector wraps Collector instances'), :32-47 (constructor: any non-null managers, at least one), :49-56 (newCollector returns MultiCollector.wrap(collectors)), :58-76 (reduce; comment at :65-66 'MultiCollector will not actually wrap the collector if only one is provided, so we check the instance type here'; unwrap at :67-68)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:43-77 (wrap: 'If the input contains 1 real collector (i.e. non-null), it is returned' at :47; single-collector return at :68-77); CollectorManager.java:28-55 (reduce over the collectors newCollector created; @lucene.experimental at :42)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MultiCollectorManager.java:24-27, 34-47, 49-56, 58-76 (comment :65-66, unwrap :67-68)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:42-77 (wrap; javadoc :47; n == 1 branch :68-77), :93 (private constructor), :168 (getCollectors)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:103-110; TopScoreDocCollector.java:36, 43-44, 51-55, 177-193; TopDocsCollector.java:94-99, 148-149; release tags 9.0.0, 10.1.0, 10.2.2 (HitQueue) and 10.3.0 (heap) in a Lucene clonea Lucene main checkout lucene/core/src/test/org/apache/lucene/search/TestCollectorManager.java:127-147 (CompositeCollectorManager); lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/utils/FacetOrchestrator.java:66-82
- Who is affected
a Lucene main checkout lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:149-165; lucene/facet/src/java/org/apache/lucene/facet/FacetsCollectorManager.java:272-293; lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:336
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/CollectorManager.java:28-32, 42, 46-55; MultiCollectorManager.java:24-27, 34-47, 65-66a Lucene main checkout lucene/core/src/test/org/apache/lucene/search/TestMultiCollectorManager.java:58-63; TestCollectorManager.java:127-147; lucene/CHANGES.txt:4344-4345, 4923-4924a Lucene clone release tags 9.0.0 and 9.1.0: lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:153 (getCollectors package-private, then public)
- Prior reports
https://github.com/apache/lucene/pull/196, https://github.com/apache/lucene/issues/11039, https://github.com/apache/lucene/pull/455, https://github.com/opensearch-project/OpenSearch/pull/8303
- TestMultiCollectorManagerSingleNested.java (one test class):
repro/LFV-198/src/org/apache/lucene/search/TestMultiCollectorManagerSingleNested.java(48 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's output.
assets/LFV-198/bug.png(482 KB)
