All findings · Lucene finding · LFV-907 · cachingcollectormanager-single-use-undocumented

CachingCollectorManager (new in 10.6.0) does not say it is single-use: a reused instance replays every earlier search

A CachingCollectorManager reused for a second search replays the hits of both searches ([0..9] instead of [3..9] in the repro), and one earlier overflow keeps isCached() false for good. Its javadoc never says an instance serves one search, unlike the sibling managers'; new in 10.6.0 (released 2026-10-29).

Summary

Priority
Low, rank 1261A documentation gap in the new experimental 10.6.0 CachingCollectorManager that only affects callers who reuse one instance, like LFV-797's reuse-after-call row but with a worse consequence of replayed hits and a stuck isCached().
Impact
minor
Since
#16247 (new class, 10.6.0, released 2026-10-29)introduced 2026-07-08
Affected
10.6.0 (released 2026-10-29), main and branch_10_6; no earlier release has the class
Status
documented
Found by
code reading of the manager's per-search state, then a public-API JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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

CachingCollectorManager records the first pass's hits per slice so a second pass can replay them. Its replay state belongs to the instance, not to one search.

Background: replaying a search with CachingCollectorManager

  1. IndexSearcher.search(query, manager) asks a CollectorManager for one collector per slice, collects, then calls reduce with those collectors to build the result.
  2. CachingCollectorManager wraps a first-pass manager: each slice's collector also records the doc ids (and optionally scores) it sees, up to a per-slice limit.
  3. After the search, isCached() says whether every slice's cache is intact. If so, replay(secondPassManager) feeds the recorded hits to a second pass; if not, you run the query again.
Summary card for CachingCollectorManager reuse: one instance used for two searches over 10 documents keeps both searches' per-slice caches in one list; after the second search replay() returns [0..9] instead of that search's [3..9], and after one overflow isCached() stays false.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 10 documents: k:a on docs 0-2, k:b on docs 3-9. Wrap a manager that keeps no state between searches (it collects doc ids) in new CachingCollectorManager<>(ids, false, null, 100) and search k:a: [0, 1, 2].
  2. Search k:b with the same instance: [3..9], correct, and isCached() is true. You expect replay(new IdsManager()) to replay this search: [3, 4, 5, 6, 7, 8, 9].
  3. Instead it returns [0, 1, ..., 9]. The manager appends every search's per-slice caches to one list, never clears it, and replays the whole list, so the second pass also gets docs 0-2, which k:b does not match.
  4. One overflow sticks: with a 5-doc budget, search k:b (7 hits) overflows, then search k:a (3 hits) fits, yet isCached() stays false, so the caller re-runs the query every time. A fresh manager says true. No error anywhere.
  5. Nothing in the javadoc says an instance is single-use, while TopScoreDocCollectorManager and TopFieldCollectorManager say "a new collectorManager should be created for each search". GroupingSearch creates a new manager per search and is not affected.

Who is affected

Direct users of the new, experimental CachingCollectorManager who keep one instance for several searches with a reusable delegate, such as their own stateless manager or the grouping module's FirstPassGroupingCollectorManager. The second pass silently gets earlier searches' hits, and after one overflow the query is always re-run. GroupingSearch is not affected. 10.6.0 (released 2026-10-29) and main only.

Mechanism, terms and numbers474 words

CachingCollectorManager is new in 10.6.0 (#16247, @lucene.experimental). It wraps a first-pass CollectorManager, caches each slice's hits and lets a second pass replay them instead of re-running the query. The caches live in one list per instance that newCollector() appends to and nothing clears, while isCached() and replay() read the whole list. So an instance used for a second search replays the hits of both searches, and one earlier overflow keeps isCached() false for every later search, silently. With a delegate that is itself reusable, each search's own result is correct. The javadoc describes one search and never says an instance is single-use, although Lucene says so for TopScoreDocCollectorManager and TopFieldCollectorManager, whose state affects results, and a code comment in TotalHitCountCollectorManager calls reuse generally supported. This is a documentation gap, not a correctness bug: no contract promises reuse. GroupingSearch, the only in-tree non-test caller, creates a new manager per search and is not affected.

The contract

  • Class javadoc: the manager "caches all collected documents (and optionally scores) per slice, so they can be replayed to a second-pass CollectorManager without re-running the query" (CachingCollectorManager.java:25-27). The example runs one search, then isCached(), then replay() or a re-run (:35-46). @lucene.experimental (:48).
  • isCached(): "Returns true if the search has been run and all per-slice caches are intact ... false if the search has not yet been run or any cache overflowed" (:103-107).
  • Nothing says what happens on a second search, either way. CollectorManager itself says nothing about reusing a manager (CollectorManager.java:23-46).

The mechanism

  1. One list per instance: private final List<CachingCollector> cachingCollectors = new ArrayList<>();, commented "One CachingCollector per slice" (CachingCollectorManager.java:58-59).
  2. newCollector() wraps a new delegate collector in a CachingCollector and calls cachingCollectors.add(cache) (:82-91). No method ever removes an entry.
  3. IndexSearcher.search(Query, CollectorManager) calls newCollector() once per slice and then reduce with exactly those collectors (IndexSearcher.java:750-791). reduce() unwraps them and calls the delegate (CachingCollectorManager.java:95-101), so with a reusable delegate each search's own result is right.
  4. isCached() is "list not empty and every cache intact" (:108-111), and replay() replays every cache in the list into a fresh second-pass collector (:118-130). After search N, both cover searches 1..N.

In the example

10 documents in one segment, no executor, so one slice per search. Search k:a adds cache 1 (docs 0-2); search k:b on the same instance adds cache 2 (docs 3-9). isCached() is true (both intact) and replay() replays both caches: [0..9]. With a 5-document budget, cache 1 of a k:b search (7 hits) overflows; cache 2 of a later k:a search (3 hits) is intact, but isCached() checks both and stays false. A reused instance also keeps every earlier cache (4 or 8 bytes per hit, CachingCollector.java:32) reachable (read, not measured).

Exposure and likelihood203 words
  • Opt-in. The class is used only when an application builds it itself (or through GroupingSearch, which creates a new one per search, GroupingSearch.java:167-176). No setting or scale is needed: 10 documents suffice.
  • Which delegates. A reusable first-pass manager: a user manager that keeps no state, or FirstPassGroupingCollectorManager, which the grouping package docs suggest wrapping in CachingCollectorManager (package-info.java:49-58); the intended-behaviour check reproduced the replay of both searches with it. With TopScoreDocCollectorManager or TopFieldCollectorManager as delegate, reuse already breaks their own documented single-use rule.
  • Effect. Silent. The second pass receives documents the latest query does not match, and duplicates where searches overlap (the independent reproduction: 21 entries with 5, 15 and 25 twice instead of 15 odd ids, with one slice and with 3 slices on a 2-thread pool). After one overflow the manager never reports a usable cache again, so the query is re-run every time. Earlier caches stay in memory. Each first-pass result is correct (with a reusable delegate).
  • Releases. 10.6.0 (released 2026-10-29 from RC1) is the first release with the class; main has it too.
  • Likelihood: rare. Few direct users of a new experimental class, and reuse with the common stateful delegates is already documented as wrong.

Minimal repro

The test shows (LFV-907, documentation defect) that a CachingCollectorManager used for two searches replays the hits of both searches, because it never clears its per-slice cache list; its javadoc does not say an instance serves one search.

TestCachingCollectorManagerReuse.java (JUnit test)47 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import java.io.IOException;
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.Directory;
import org.apache.lucene.tests.search.DummyTotalHitCountCollector;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCachingCollectorManagerReuse extends LuceneTestCase {

  public void testReplayAfterSecondSearch() throws IOException {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(new Document());
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        CachingCollectorManager<DummyTotalHitCountCollector, Integer> caching =
            new CachingCollectorManager<>(
                DummyTotalHitCountCollector.createManager(), false, null, 10);
        searcher.search(MatchAllDocsQuery.INSTANCE, caching);
        searcher.search(MatchAllDocsQuery.INSTANCE, caching);
        // expected: replay() replays the 1 hit of the last search; actual: 2 (both searches)
        assertEquals(1, (int) caching.replay(DummyTotalHitCountCollector.createManager()));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-907/, 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: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.TestCachingCollectorManagerReuse
Expected vs actual
Expected (after searching MatchAllDocsQuery twice over one document with a stateless delegate): replay() returns the last search's 1 hit; actual (-ea and -da): 2. The intended fix is a javadoc sentence, so this test documents the gap rather than a future green test.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<1> but was:<2>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.search.TestCachingCollectorManagerReuse.testReplayAfterSecondSearch(TestCachingCollectorManagerReuse.java:43)

Why it looks unintended

  • TopScoreDocCollectorManager and TopFieldCollectorManager say "a new collectorManager should be created for each search due to its internal states"; this class says nothing.
  • A TotalHitCountCollectorManager code comment calls reuse "generally supported" (not a strict requirement).
  • In #13542 javanna, who approved #16247, wrote that a stateful manager must be cleaned between searches or declared non-reusable; this one does neither, silently.
The full argument307 words
  • Lucene documents single use where it applies. TopScoreDocCollectorManager.java:26 and TopFieldCollectorManager.java:27: "Note that a new collectorManager should be created for each search due to its internal states." (added by #240, LUCENE-10002, 2023).
  • A code comment states a reuse norm. TotalHitCountCollectorManager.java:87-89 (an implementation comment in reduce(), not javadoc; #13542, 2024): "Make the same collector manager instance reusable across multiple searches. It isn't a strict requirement but it is generally supported as collector managers normally don't hold state, as opposed to collectors." It is qualified and is no interface contract.
  • The rule a reviewer stated (as javanna wrote in the #13542 review, 2024-07-22). "Because the collector manager holds state, either we clean it in-between searches ... or we declare that collector managers cannot be re-used across multiple searches (potential breaking change)." The same comment goes on: "I am unsure whether it is necessary to make a breaking change for this, perhaps clearing state in reduce is not that bad." So javanna leaned toward cleaning state over a breaking declaration (said of an already released manager, as this one now is in 10.6.0), which supports the code option below at least as much as the javadoc one. javanna also reviewed and approved #16247 (2026-07-08). CachingCollectorManager holds state, does not clean it and does not declare it non-reusable.
  • Never discussed. The #16247 review (39 inline comments) covered the list's concurrency ("looks like this list is needed for the replay functionality"; newCollector is called from one thread), the volatile cached flag and isCached() before any search, never a second search.
  • No test either way. Upstream TestCachingCollectorManager (testCacheOverflow, testNotCachedBeforeSearch, testBasic, testConstructor) uses each instance for at most one search.
  • What is not claimed. No contract promises that a reused manager replays only its latest search, so this is filed as a javadoc gap, not a correctness bug.
The devil's-advocate check214 words

The strongest case (the intended-behaviour check and the refute-lens cross-model review): the class is a per-search result holder by design. Its javadoc speaks of "the search" (CachingCollectorManager.java:104) and shows a single search (:35-46); "caches all collected documents" (:25) is literally what a reused instance does; it is @lucene.experimental; CollectorManager promises nothing about reuse; TotalHitCountCollectorManager calls reuse "not a strict requirement"; and GroupingSearch uses one instance per search. The refute-lens reviewer (OpenAI GPT-5.6) rated it not real, with high confidence, as a correctness bug.

Why it fails against the documentation claim: it defeats a correctness framing, which is not made here, but not the gap. No text says one instance must not serve two searches; Lucene writes that sentence for TopScoreDocCollectorManager and TopFieldCollectorManager, whose state affects results; a code comment calls reuse generally supported; and the failure is silent. The refute-lens reviewer itself agreed that a sibling-style sentence would reduce surprise. Remaining risk: maintainers may find the singular "the search" sufficient (judge confidence medium). The hunt's own noise audit first triaged it as a noise candidate (documented/defensible) before a challenger kept it as a Low documentation claim, and the credibility audit expects a one-line javadoc PR to be welcome but push-back on any bug or regression wording.

Suggested fix

Recommended One javadoc sentence, like the sibling managers' (validated)

Add one sentence to the class javadoc, as the sibling managers have: "a new CachingCollectorManager should be created for each search", saying that isCached() and replay cover every search run on the instance (+4 lines). Format, lint and javadoc checks pass. A code alternative, replaying only the most recent search, was also validated.

  * </pre>
  *
+ * <p>Note that a new CachingCollectorManager should be created for each search: it keeps the
+ * per-slice caches of every search it is used for, and {@link #isCached()} and {@link #replay}
+ * cover all of them.
+ *
  * @lucene.experimental
All options and trade-offs3 options ยท 561 words

Both options were built in a scratch worktree of 645d4fe14a20 and run with gradle. The javadoc sentence matches the advice of the adjudication and the verifiers; upstream may prefer the small code change, whose regression test is the repro.

One javadoc sentence, like the sibling managers' (validated) (recommended)

In the class javadoc of CachingCollectorManager, after the usage example (1 file, +4).

  * </pre>
  *
+ * <p>Note that a new CachingCollectorManager should be created for each search: it keeps the
+ * per-slice caches of every search it is used for, and {@link #isCached()} and {@link #replay}
+ * cover all of them.
+ *
  * @lucene.experimental

Trade-offs

Smallest change, mirrors existing wording, no behaviour change. Reuse stays a silent misuse, and a reused instance still keeps earlier caches in memory.

What the checks validated

  • :lucene:core:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • :lucene:core:renderJavadoc passes (its log records only BUILD SUCCESSFUL); the independent fact check rebuilt the javadoc and found the note in the rendered class page.
  • The 8 upstream TestCachingCollector* tests pass; the repro still fails, as it must for a javadoc-only change.

newCollector() empties the replayable set when a search starts, and reduce() publishes exactly the collectors it receives, which are this search's (1 file, +10/-4, including one class-javadoc line: "isCached() and replay refer to the most recent search run with this manager"). The excerpt below has verbatim diff lines with shortened hunk headers and leaves out the javadoc hunk.

-  // One CachingCollector per slice
-  private final List<CachingCollector> cachingCollectors = new ArrayList<>();
+  // One CachingCollector per slice of the most recent search: set by reduce, emptied when a new
+  // search starts
+  private volatile List<CachingCollector> cachingCollectors = List.of();
@@ newCollector()
+    // a new search starts: the caches of an earlier search must not be replayed
+    cachingCollectors = List.of();
@@
-    cachingCollectors.add(cache);
@@ reduce()
-    return delegate.reduce(originals);
+    R result = delegate.reduce(originals);
+    cachingCollectors = List.copyOf(collectors);
+    return result;

Trade-offs

  • With a reusable delegate, reuse just works: a reused instance replays only its latest search and no longer keeps earlier caches alive. It does not make a stateful delegate (such as TopScoreDocCollectorManager) reusable. GroupingSearch and every single-use caller behave as before.
  • A slightly different edge: after a search that throws before reduce(), isCached() is false (today it may report earlier caches).
  • Two concurrent searches through one instance stay unsupported, as today.
  • Touches the same method as the LFV-910 fix (independent logic, a trivial rebase).

What the checks validated

  • Stock + test: fails with assertions on and off.
  • + fix: the test passes with assertions on and off; core TestCachingCollector, TestCachingCollectorManager, the new test, TestIndexSearcher, TestMultiCollector, TestMultiCollectorManager (39 tests, 1 skipped) and the whole lucene/grouping module (56 tests), 0 failures, assertions on and off.
  • :lucene:core:check -x test passes after one re-wrap of the javadoc line.
  • The independent reproduction's 3 tests (one slice, 3 slices on a 2-thread pool, overflow then fit) fail 3 of 3 on stock 645d4fe14a20 and pass 3 of 3 with the patched class.
  • Not run: the full test suite, nightly tests, a benchmark.

Throw on a second use (not built)

newCollector() throws IllegalStateException once the instance has been reduced.

Trade-offs

Turns silent misuse into an error, but is stricter than the sibling managers, which only document it, and goes against the "generally reusable" norm.

Downstream note

Only application code decides to reuse an instance; an application that shared one across requests would feed earlier requests' hits, including documents the latest query or filter excludes, into a later second pass.

How it was found

Code reading: while modelling GroupingSearch's replay-or-re-run decision on main, a hunt unit noticed that the manager's caches belong to the instance and are never cleared. It reduced the effect to a public-API JUnit with a stateless delegate and framed it as a documentation gap.

The method in full115 words
  • Earlier sightings (this hunt). A unit on concurrent collector managers saw the effect in a scratch probe (3 + 7 = 10 replayed hits instead of 7) and left it unreported as a grey zone. In planning round 12, an ideation pack by an independent model from another family (OpenAI GPT-5.6) and a release-review probe proposed it again on main a9779ef3c193.
  • Hunt unit. Read the per-search state while modelling the replay decision, reduced it to the JUnit in this doc (a delegate that keeps no state, so only the CachingCollectorManager carries state across searches), and classified it as a documentation gap, since no contract promises reuse.
  • Method tier. Non-formal: code reading and executed tests.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-28: two automated searches of 55 queries over GitHub, commits, the Lucene lists, JIRA, downstream trackers and the web; 23 manual GitHub searches; all 411 open PRs' changed files), refreshed 2026-10-29. Only #16247 (introducing) and #16379 name the class.

Searches4 searches

Nobody has reported that a reused CachingCollectorManager replays earlier searches or keeps isCached() false, or asked for a single-use note. Related context, not reports: #240 (the sibling managers' single-use sentence), #13542 (the reuse norm and javanna's clean-or-declare remark), #15605 / #15608 (a similar per-instance list removed from TopFieldCollectorManager for thread safety).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Solr, Elasticsearch, OpenSearch)2026-10-28two scripted prior-report searches (55 queries each: class and method names, reuse and single-use wording, the sibling sentence) and 23 manual searchesno report; only #16247 and #16379 name the class; context: #240, #13542, #15605, #15608, #5438, #4175, LUCENE-10002
Open apache/lucene PRs2026-10-28changed files of all 411 open PRs; the file's commit history on main, branch_10_6 and branch_10xnone touches CachingCollectorManager.java; its only commit is #16247
dev@, java-user@, issues@, commits@ lists and JIRA2026-10-28list searches over the last year; LUCENE-10002 with its commentsno mention of CachingCollectorManager on dev@ or java-user@; issues@ only the #16247 and #16379 notifications
GitHub refresh2026-10-29git ls-remote; issue and PR searches for CachingCollectorManager, "... reuse", "... single" and the sibling sentence; changed files of each of the 24 open PRs updated since 2026-10-28 (file lists kept)nothing new; no open PR touches a CachingCollector* or GroupingSearch file; main still 645d4fe14a20, branch_10_6 still 4ae946e5a9b9, no 10.6 tag

History

Introduced with the class by #16247 (gaobinlong, approved by javanna, merged 2026-07-08; main 3afffeef5a8d, branch_10x d60e550b47f), which is in 10.6.0 (released 2026-10-29). No earlier release has the class; the file is unchanged since.

Versions tested6 rows

The never-cleared list and the javadoc without a single-use note come from the introducing commit; its parent 6f3c1d7ad69 has no such file. CachingCollectorManager.java is byte-identical at 3afffeef5a8d, d60e550b47f, branch_10_6 4ae946e5a9b9 and main a9779ef3c193, and unchanged through ea57360912ed to 645d4fe14a20. Before 10.6, GroupingSearch used one CachingCollector once, so there was nothing to reuse. Listed under 10.6.0 in CHANGES.txt:543. A new API, not a regression.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-method test fails, [0..9] instead of [3..9], assertions on and off (gradle and standalone)
branch_10_6 4ae946e5a9b9 (the 10.6.0 RC1 revision; jars built from source, not the RC1 release artifacts) affectedthis doc's and the finder's test fail; the independent reproduction and the intended-behaviour check reproduce with their own tests; a separate test, which asserts the accumulation, passes
main a9779ef3c193 affectedthe finder's, the independent reproduction's and the intended-behaviour check's tests fail (judge and re-runs too), assertions on and off; a separate test, which asserts the accumulation, passes (verifier-step re-run)
main ea57360912ed affectedthe cross-model reviews' own tests, assertions on and off: the refute-lens test fails (replay counts 5 instead of 3), the cross-model review's accumulation test passes
3afffeef5a8d (#16247) sources over the 10.5.2 jars introducing committhe test fails (assertions on)
10.5.2, 10.5.0, 10.4.0, 9.12.3 no such classcompile error: CachingCollectorManager does not exist

Independent checks

Two executed cross-model reviews (the second is verifier), the independent reproduction and the intended-behaviour check and an adjudication agree on the mechanism; all rate it a documentation gap, and the refute-lens review rejects a correctness framing. This doc's test fails on main and the release candidate, and passes with the code fix.

Checks and verdicts314 words

Every check ran code. The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) on main ea57360912ed (2026-10-24), where the relevant classes are identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidate (category doc)Public-API JUnit with a stateless delegate: fails 1 of 1 on main (-ea, -da) and branch_10_6 (-ea; the follow-up step added the -da run); 10.5.2 compile error.
cross-model review, refute lens not real as a correctness bug (high); agreed a single-use sentence would helpOwn JUnit on main ea57360912ed (-ea, -da): replay counted 5 instead of 3; a fresh-manager control passed.
Independent reproducer real, documentation onlyOwn JUnit (30 docs, 3 segments, overlapping queries, one slice and 3 slices, overflow then fit): 3 of 3 fail on main and branch_10_6 (-ea, -da).
Cross-model review, intended lens real (high); intended argument moderateOwn JUnit asserting the accumulation and the sticky overflow passes on main ea57360912ed (-ea, -da); the verifier step re-ran it on main a9779ef3c193 (-ea, -da) and branch_10_6 (-ea): passes.
Devil's advocate real, documentation onlyWorks-as-designed case strong against a correctness framing, not against the gap; probe with FirstPassGroupingCollectorManager as delegate replays [0..9] instead of [3..9].
adjudication confirmed, medium (minor, Low)Re-ran the independent reproduction's test (3 of 3 fail, -ea, -da) and the finder's (1 of 1 fail).
credibility audit keepRe-ran the finder's test on main (-da): fails; prior art refreshed.
this doc as aboveThe test on 645d4fe14a20 (gradle and standalone, -ea, -da), jars built from branch_10_6 at the RC1 revision and 10.5.2; both fixes in a scratch worktree; the independent reproduction's test with and without the code fix.

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

Finding record

LFV-907, snapshot of 2026-11-06

Registry title
CachingCollectorManager (new in 10.6.0) does not document that it is single-use: reused for a second search, replay() replays the hits of both searches and one earlier overflow keeps isCached() false
Area
search / CachingCollectorManager lifecycle (documentation; new public API in 10.6.0)
Priority
Low, rank 1261: A documentation gap in the new experimental 10.6.0 CachingCollectorManager that only affects callers who reuse one instance, like LFV-797's reuse-after-call row but with a worse consequence of replayed hits and a stuck isCached().
Impact class
minor
Impact note
Documentation gap only, Low: direct users of the new @lucene.experimental CachingCollectorManager (10.6.0, released 2026-10-29, and main) who keep one instance for more than one search get a replay() that delivers the hits of every search run on that instance so far and, after any earlier overflow, an isCached() that stays false, so the query is always re-run; every search's own first-pass result is correct, no scale or setting is needed, and GroupingSearch, the only in-tree user, creates a new manager per search and is not affected.
Summary
You wrap a reusable CollectorManager in a CachingCollectorManager, run a search with it, then run a second search with the same instance and call isCached() and replay(secondPassManager), the workflow the class javadoc shows for one search. Nothing in its javadoc says an instance serves only one search, unlike TopScoreDocCollectorManager and TopFieldCollectorManager, which say 'a new collectorManager should be created for each search due to its internal states', while Lucene's stated norm (TotalHitCountCollectorManager) is that managers are generally reusable. Instead the manager keeps the per-slice caches of every search in one list that is never cleared, so replay() returns the hits of both searches (the repro: [0..9] instead of the second search's [3..9]) and one earlier overflow makes isCached() false for every later search, with no error.
Introduced
3afffeef5a8d3bcbdcff9f2376e1c5a357764197 on main (squash merge of apache/lucene#16247 'Introduce CachingCollectorManager to parallelize search when using CachingCollector', by gaobinlong) and its backport d60e550b47f (branch_10x, contained in branch_10_6). The class, its never-cleared cachingCollectors list and its javadoc without a single-use note all come from that commit; the parent 6f3c1d7ad69 has no such file [read: git log, git log --all -S CachingCollectorManager in the full-history clone, upstream commits API; exec: the introducing commit's CachingCollectorManager.java and CachingCollector.java shadowed over the 10.5.2 jars fail the repro]. A new class, not a regression. (2026-07-08 (author date 2026-07-08T09:27:53Z on both commits; PR merged 2026-07-08T09:27:54Z; backport committed 2026-07-08T09:29:13Z))
First affected version
10.6.0 (released 2026-10-29; branch_10_6 head 4ae946e5a9b [exec]); no earlier release has the class (compile error on 9.12.3, 10.4.0, 10.5.0 and 10.5.2 [exec])
Affected versions
main a9779ef3c193 [exec: finder JUnit fails with -ea and -da; the independent reproduction JUnit fails 3 of 3 with -ea and -da; the reviewer's and the intended-behaviour check's own tests reproduce] and the branch_10_6 head 4ae946e5a9b, the 10.6.0 release line [exec: finder JUnit -ea and -da, the independent reproduction JUnit 3 of 3 -ea and -da, reviewer test, the intended-behaviour check probe]; CachingCollectorManager.java is byte-identical at 3afffeef5a8d, d60e550b47f, 4ae946e5a9b and a9779ef3c193 [read]. The 10.6.0 RC artifacts themselves were not run (the branch_10_6 head build was). Not affected: every release up to and including 10.5.2, where the class does not exist [exec on 9.12.3, 10.4.0, 10.5.0, 10.5.2]; before 10.6 GroupingSearch wrapped a single CachingCollector used once, with no manager to reuse [read]. Not fixed upstream: the only commit to the file on main (head c5ab3e57b9ba) and on branch_10_6 (head 4ae946e5a9b9) is #16247, and no open PR touches it (checked 2026-10-28) [read]. All runs with JDK 25 on aarch64 Linux.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (the prior-report record, verdict new; searched 2026-10-28, read-only; refreshed at the registration check 2026-10-28): two scripted prior-report searches (55 queries each over GitHub issues, PRs and commits, downstream trackers, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web), 23 manual GitHub issue/PR searches (including elasticsearch, OpenSearch and solr), a GitHub code search, the changed files of all 411 open apache/lucene PRs, the file's upstream commit history on main, branch_10_6 and branch_10x, list-archive statistics for the last year, LUCENE-10002 with its comments, and full reads of the #16247 review and of #240, #13542, #15605, #15608 and related items. Only #16247 (the introducing PR) and #16379 (TestGrouping) name the class; neither discusses reuse, and the #16247 review covered the list's concurrency, the volatile cached flag and isCached() before any search, not reuse. Context, not reports: #240 added the sibling managers' 'a new collectorManager should be created for each search' note; the #13542 review (2024) states that a stateful manager must be cleaned between searches or declared non-reusable; #15605/#15608 removed a similar per-instance collector list from TopFieldCollectorManager for thread safety. Refresh: the class search still returns only #16247 and #16379; 'CachingCollectorManager reuse', 'CachingCollectorManager javadoc' and the sibling sentence return nothing; of the 65 PRs updated since 2026-10-26 only #16857 (Sort pruning) touches a collector manager, TopFieldCollectorManager.java, and its diff touches neither the single-use note nor CachingCollector*.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28, upstream main on 2026-10-29): the one-method repro test through gradle in a scratch worktree and through the standalone runner, assertions on and off, stock, with the javadoc fix (checks, javadoc build) and with the keep-the-last-search code fix (repro, core caching tests, grouping module, checks); the independent reproduction's three-method test stock and with the code fix; jars built from branch_10_6 4ae946e5a9b9, the 10.6.0 RC1 revision (the RC1 release artifacts themselves were not run); the 10.5.2 release jars (compile error: no such class). Earlier hunt runs used main a9779ef3c193, except the cross-model reviews, which ran on main ea57360912ed; CachingCollectorManager.java, CachingCollector.java, IndexSearcher.java, CollectorManager.java and lucene/grouping are identical at all three commits.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:24-49 (at a9779ef3c193) (class javadoc: single-search example at :35-46, no reuse statement, @lucene.experimental at :48), :58-59 (per-instance cachingCollectors list), :82-91 (newCollector appends at :89, nothing clears the list), :93-101 (reduce uses only the current search's collectors), :103-111 (isCached javadoc 'the search has been run', allMatch over the whole list), :113-130 (replay over the whole list)
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:26 (at a9779ef3c193) and TopFieldCollectorManager.java:27 ('Note that a new collectorManager should be created for each search due to its internal states'); TotalHitCountCollectorManager.java:87-89 (reuse 'isn't a strict requirement but it is generally supported as collector managers normally don't hold state'); CollectorManager.java:23-46 (no reuse statement); lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:167-176 (a new manager per search)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:25-27, :35-46, :48, :58-59, :82-91, :95-101, :103-111, :118-130
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:750-791; CollectorManager.java:23-46; CachingCollector.java:32
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:167-176; lucene/grouping/src/java/org/apache/lucene/search/grouping/package-info.java:49-58
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:26; TopFieldCollectorManager.java:27; TotalHitCountCollectorManager.java:87-89; CollectorManager.java:23-46
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CachingCollectorManager.java:25, :35-46, :48, :104
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestCachingCollectorManager.java (each instance used for at most one search)
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:543 (10.6.0 section from :456); git log of CachingCollectorManager.java (only 3afffeef5a8)

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

Repro files

About the diagrams

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

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.